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:
| Field | Type | Notes |
|---|---|---|
rule_type | "global" | "domain" | "email" | What the rule matches against. |
value | string | Lowercased email or domain. Empty for global. |
reason | string | Free-text. Surfaced verbatim in the 403 response shown to the blocked user. |
expires_at | datetime | null | Auto-unblock time. null means “permanent until manually deleted”. |
created_by | string | User id of the admin who created the rule. |
created_at | datetime |
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
403 Forbidden with code AUTHORIZATION_ERROR.
Enforcement points
The block check runs on four API entry points. All produce the same403 response when a matching active rule exists:
| Entry point | What it gates |
|---|---|
POST /v1/auth/login | New password logins. |
POST /v1/auth/cas/login | New CAS SSO logins. |
POST /v1/auth/refresh | Renewal of an existing access token. |
POST /v1/sessions/initialize | Starting a new MIND voice session. |
- 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
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
include_expired=true for an audit view that also shows rules whose expires_at has passed.
Query parameters
When
true, also returns rules whose expires_at is in the past. Useful for audit and historical review.Example response
Create Rule (generic)
pause-all, block-email, block-emails, block-domain) which are friendlier shapes for common actions.
Request body
One of
"global", "domain", "email".Required for
"email" and "domain" rules. Ignored for "global". Lowercased before storage.RFC 3339 timestamp. Must be strictly in the future. Omit for an indefinite block.
Free-text. Surfaced verbatim in the 403 response and on the dashboard.
Errors
| Status | Code | Condition |
|---|---|---|
422 | VALIDATION_ERROR | email rule with no @, or domain rule with empty value, or expires_at not in the future. |
403 | AUTHORIZATION_ERROR | Caller is not super_admin / admin. |
Delete Rule
expires_at, or to clean up rules that were created in error.
Errors
| Status | Code | Condition |
|---|---|---|
404 | NOT_FOUND | No rule with this id. |
Pause All
Request body
Auto-resume time. Omit for an indefinite pause (until you manually
DELETE the rule).Surfaced to blocked users as the body of the 403 response.
Example request
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
Request body
RFC 5322 email. Lowercased before storage, so
Foo@MIVA.EDU.NG and foo@miva.edu.ng are the same rule.Auto-unblock time. Omit for indefinite.
Surfaced to the blocked user.
Example request
Block Multiple Emails
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
Non-empty list of RFC 5322 emails.
Same
expires_at applied to every created rule.Same reason applied to every created rule.
Example request
Example response
Block Domain
Request body
Email domain. Leading
@ is optional — @miva.edu.ng and miva.edu.ng produce the same rule. Lowercased before storage.Auto-unblock time. Omit for indefinite.
Surfaced to blocked users.
Example request — pause @miva.edu.ng for two hours
Common flows
Pause everyone for 30 minutes
13:30 UTC. No manual delete required.
Pause @miva.edu.ng indefinitely, resume manually
Block a list of students for one week
2026-05-13.
Audit history
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:
- Mutations invalidate immediately. Every
POST/DELETEclears 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. - 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_athas just passed — the user could be blocked or unblocked up to 60 seconds late.
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:| Method | When to use |
|---|---|
Auto — set expires_at at create time | Scheduled pauses, maintenance windows, “for the next 2 hours”. |
Manual — DELETE /access/rules/{id} | Indefinite blocks, pilot pauses you’ll lift on demand, mistakes. |
?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_byandcreated_at. Every mutation logsblock_rule_created/block_rule_deletedto 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.ngis the standard “pause student traffic” lever. Setexpires_atto 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.
reasonis shown to the blocked user. Keep it human-readable. Avoid internal jargon.
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 atinitialize_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.