Skip to main content
The access control module is an operational kill-switch that lets a super admin block users from logging into and using MIND. Use it to manage the LiveKit build-plan concurrency cap during the pilot, to pause a misbehaving cohort, or as an incident response control going forward. Block rules are platform-wide (not tenant-scoped). They take effect within the same request that creates them — there is no propagation lag in single-instance deployments. Time-bounded rules auto-expire, so most operations don’t need a manual “unblock” step.
All endpoints in this section live under /v1/console/access and require the caller to hold the super_admin or admin role. Other roles get 403 Forbidden.

Concept model

A single collection — block_rules — drives the entire system. Each row is one rule:
FieldTypeNotes
rule_type"global" | "domain" | "email"What the rule matches against.
valuestringLowercased email or domain. Empty for global.
reasonstringFree-text. Surfaced verbatim in the 403 response shown to the blocked user.
expires_atdatetime | nullAuto-unblock time. null means “permanent until manually deleted”.
created_bystringUser id of the admin who created the rule.
created_atdatetime
A rule is active when expires_at is null or strictly in the future. Expired rules are filtered at lookup time — there is no background sweeper, and an expired row contributes nothing to access decisions even if it physically still exists. This makes auto-unblock free. Multiple rules can match the same user (e.g. a global block plus their email plus their domain); the first match wins and the response carries that match’s reason.

Authorization

Authorization: Bearer <jwt-with-super_admin-or-admin-role>
Anything else returns 403 Forbidden with code AUTHORIZATION_ERROR.

Enforcement points

The block check runs on four API entry points. All produce the same 403 response when a matching active rule exists:
Entry pointWhat it gates
POST /v1/auth/loginNew password logins.
POST /v1/auth/cas/loginNew CAS SSO logins.
POST /v1/auth/refreshRenewal of an existing access token.
POST /v1/sessions/initializeStarting a new MIND voice session.
Together these give a “MIND-wide” block:
  • No new logins → blocked accounts can’t authenticate.
  • No refresh → existing access tokens expire within their JWT TTL (~30 minutes by default), then refresh fails.
  • No new sessions → defense in depth for any token still inside its access-token TTL.
  • Active voice session → continues until naturally ended. The active LiveKit room uses a separate room token issued at session creation; blocking the user does not kick them out of an in-progress conversation.

Response shape when blocked

HTTP/1.1 403 Forbidden
Content-Type: application/json

{
  "success": false,
  "error": {
    "code": "AUTHORIZATION_ERROR",
    "message": "Access temporarily paused"
  }
}
message carries the rule’s reason if one was set, or the literal default "Access temporarily paused" otherwise. Frontend should display this verbatim and route the user to a “contact administrator” CTA.

List Rules

GET /v1/console/access/rules
Returns currently active block rules. Pass include_expired=true for an audit view that also shows rules whose expires_at has passed.

Query parameters

include_expired
boolean
default:"false"
When true, also returns rules whose expires_at is in the past. Useful for audit and historical review.

Example response

{
  "success": true,
  "data": [
    {
      "id": "664a1f4e9b8f3c2a1d7e0001",
      "rule_type": "domain",
      "value": "miva.edu.ng",
      "reason": "build plan ceiling",
      "expires_at": "2026-05-06T15:00:00Z",
      "is_expired": false,
      "created_by": "664a0011aaaa2222bbbb3333",
      "created_at": "2026-05-06T13:00:00Z"
    }
  ]
}

Create Rule (generic)

POST /v1/console/access/rules
Generic create — accepts any rule type. Mostly used by tooling and scripts. The dashboard will normally call the convenience endpoints below (pause-all, block-email, block-emails, block-domain) which are friendlier shapes for common actions.

Request body

rule_type
string
required
One of "global", "domain", "email".
value
string
Required for "email" and "domain" rules. Ignored for "global". Lowercased before storage.
expires_at
datetime
RFC 3339 timestamp. Must be strictly in the future. Omit for an indefinite block.
reason
string
Free-text. Surfaced verbatim in the 403 response and on the dashboard.

Errors

StatusCodeCondition
422VALIDATION_ERRORemail rule with no @, or domain rule with empty value, or expires_at not in the future.
403AUTHORIZATION_ERRORCaller is not super_admin / admin.

Delete Rule

DELETE /v1/console/access/rules/{rule_id}
Removes a rule immediately. Use this to manually lift a block before its expires_at, or to clean up rules that were created in error.

Errors

StatusCodeCondition
404NOT_FOUNDNo rule with this id.

Pause All

POST /v1/console/access/pause-all
The flagship kill-switch. Blocks every email at once. Use for scheduled maintenance windows, incident response, and to stay under the LiveKit build-plan concurrency cap during the pilot.

Request body

expires_at
datetime
Auto-resume time. Omit for an indefinite pause (until you manually DELETE the rule).
reason
string
Surfaced to blocked users as the body of the 403 response.

Example request

curl -X POST https://mind-be.staging.miva.university/v1/console/access/pause-all \
  -H "Authorization: Bearer <access_token>" \
  -H "Content-Type: application/json" \
  -d '{
    "expires_at": "2026-05-06T18:00:00Z",
    "reason": "scheduled maintenance"
  }'
Pausing all does not cascade-cancel rules of other types. Per-email or per-domain rules continue to exist; they’re just redundant while a global rule is active.

Block Email

POST /v1/console/access/block-email
Block a single email.

Request body

email
string
required
RFC 5322 email. Lowercased before storage, so Foo@MIVA.EDU.NG and foo@miva.edu.ng are the same rule.
expires_at
datetime
Auto-unblock time. Omit for indefinite.
reason
string
Surfaced to the blocked user.

Example request

curl -X POST https://mind-be.staging.miva.university/v1/console/access/block-email \
  -H "Authorization: Bearer <access_token>" \
  -H "Content-Type: application/json" \
  -d '{
    "email": "student@miva.edu.ng",
    "expires_at": "2026-05-07T00:00:00Z",
    "reason": "abuse review"
  }'

Block Multiple Emails

POST /v1/console/access/block-emails
Bulk version of block-email. Creates one rule per email in a single round trip. Duplicates and invalid entries are silently skipped — the response tells you how many rules were actually created.

Request body

emails
string[]
required
Non-empty list of RFC 5322 emails.
expires_at
datetime
Same expires_at applied to every created rule.
reason
string
Same reason applied to every created rule.

Example request

curl -X POST https://mind-be.staging.miva.university/v1/console/access/block-emails \
  -H "Authorization: Bearer <access_token>" \
  -H "Content-Type: application/json" \
  -d '{
    "emails": ["a@miva.edu.ng", "b@miva.edu.ng", "c@miva.edu.ng"],
    "expires_at": "2026-05-13T00:00:00Z",
    "reason": "pilot cohort B paused"
  }'

Example response

{
  "success": true,
  "data": [
    { "id": "...", "rule_type": "email", "value": "a@miva.edu.ng" },
    { "id": "...", "rule_type": "email", "value": "b@miva.edu.ng" },
    { "id": "...", "rule_type": "email", "value": "c@miva.edu.ng" }
  ],
  "message": "Blocked 3 email(s)"
}

Block Domain

POST /v1/console/access/block-domain
Block every email on a domain.

Request body

domain
string
required
Email domain. Leading @ is optional — @miva.edu.ng and miva.edu.ng produce the same rule. Lowercased before storage.
expires_at
datetime
Auto-unblock time. Omit for indefinite.
reason
string
Surfaced to blocked users.

Example request — pause @miva.edu.ng for two hours

curl -X POST https://mind-be.staging.miva.university/v1/console/access/block-domain \
  -H "Authorization: Bearer <access_token>" \
  -H "Content-Type: application/json" \
  -d '{
    "domain": "miva.edu.ng",
    "expires_at": "2026-05-06T15:00:00Z",
    "reason": "build plan ceiling — pause student traffic for evaluation"
  }'

Common flows

Pause everyone for 30 minutes

curl -X POST .../v1/console/access/pause-all \
  -H "Authorization: Bearer <access_token>" \
  -d '{ "expires_at": "2026-05-06T13:30:00Z", "reason": "deploy in progress" }'
Auto-resumes at 13:30 UTC. No manual delete required.

Pause @miva.edu.ng indefinitely, resume manually

# Step 1 — create the block (returns the rule id)
curl -X POST .../v1/console/access/block-domain \
  -H "Authorization: Bearer <access_token>" \
  -d '{ "domain": "miva.edu.ng", "reason": "investigation" }'

# Step 2 — list to find the id later (optional)
curl .../v1/console/access/rules \
  -H "Authorization: Bearer <access_token>"

# Step 3 — unblock when ready
curl -X DELETE .../v1/console/access/rules/<rule_id> \
  -H "Authorization: Bearer <access_token>"

Block a list of students for one week

curl -X POST .../v1/console/access/block-emails \
  -H "Authorization: Bearer <access_token>" \
  -d '{
    "emails": ["s1@miva.edu.ng", "s2@miva.edu.ng", "s3@miva.edu.ng"],
    "expires_at": "2026-05-13T00:00:00Z",
    "reason": "grading review"
  }'
All three auto-unblock on 2026-05-13.

Audit history

curl ".../v1/console/access/rules?include_expired=true" \
  -H "Authorization: Bearer <access_token>"

Caching semantics

AccessControlService keeps a 60-second in-memory snapshot of all rules to keep is_blocked(email) fast and to avoid hammering Mongo on every login or session start. Two invariants:
  1. Mutations invalidate immediately. Every POST / DELETE clears the cache, so the next request reads from Mongo and sees the new state. There is no “wait 60 seconds for the block to take effect” lag in single-process deployments.
  2. Reads are bounded by the TTL. Without a mutation, the cache may serve a snapshot up to 60 seconds old. In practice this only matters when a rule’s expires_at has just passed — the user could be blocked or unblocked up to 60 seconds late.
The cache is per worker process (it lives in module-level state). With multiple Uvicorn / FastAPI workers, each worker holds its own cache and invalidates only on mutations it observes. A POST from worker A is not seen by worker B until worker B’s TTL expires — bounded staleness, max 60 seconds, well within the operational latency budget for this control.

Time-based expiry

Two ways to lift a block:
MethodWhen to use
Auto — set expires_at at create timeScheduled pauses, maintenance windows, “for the next 2 hours”.
ManualDELETE /access/rules/{id}Indefinite blocks, pilot pauses you’ll lift on demand, mistakes.
Expired rules are filtered at lookup time. There is no background job. Expired rows remain in Mongo (visible via ?include_expired=true) for audit. Reclaiming storage by periodically deleting expired rows is optional and not required for correctness.

Operational notes

  • Audit trail. Every rule has created_by and created_at. Every mutation logs block_rule_created / block_rule_deleted to Logfire with the rule id, type, value, and actor id.
  • Pilot concurrency cap. With LiveKit on the build plan limited to 5 concurrent sessions, a domain block on miva.edu.ng is the standard “pause student traffic” lever. Set expires_at to your evaluation window.
  • No allowlist semantics today. The model is deny-only. If you need “block the domain except a few students,” the practical pattern is the inverse: keep the domain unblocked and block the small set of students you want to exclude.
  • reason is shown to the blocked user. Keep it human-readable. Avoid internal jargon.
Don’t lock yourself out. A super admin can still issue pause-all while themselves having access — but if you also block the domain you log in from, the next refresh cycle will lock you out. Use expires_at defensively when self-experimenting.

FAQ

A user is mid-conversation when I block them. Are they kicked out? No. The active LiveKit voice session uses a room token issued at initialize_session; the API JWT is separate. The user finishes that session naturally. They cannot start a new session afterwards, and once their access token expires (~30 min by default) they cannot refresh. Does blocking delete user data or invalidate their tokens immediately? Neither. Tokens remain valid until expiry; data is untouched. This is a soft, fully reversible control. Can I block someone before they have a user account? Yes — block their email or domain. The check runs before any user lookup at login, so a never-seen email is refused at the door. Is there a per-tenant version of this? No. Block rules are platform-wide. If a per-tenant control is needed later, the natural extension is a tenant_id: string | null column on BlockRule with null meaning platform-wide. Not implemented today. What if I want an allowlist (only specific emails can use MIND)? Not implemented. The current model is deny-only. An allowlist would require an inverted evaluation path; design before adding.