Access — users, roles and permissions
The one capability that decides whether the app can serve a request at all. It is implemented in-process today, behind an interface, so moving it into a plugin is a wiring change rather than a rewrite.
The contract
type Access interface {
Authenticate(ctx, username, password string) (*Principal, error)
Can(ctx, userID, resource, action string) (Decision, error)
InvalidateUser(ctx, userID string)
InvalidateRole(ctx, roleCode string)
InvalidateAll(ctx)
}
type Decision struct {
Allowed bool
Scope Scope // all | assigned | own — returned, never enforced here
}
Three rules that shaped it:
resourceis part of the check. A cache key ofuserID → {resource → {read, write, delete, scope}}answers every gate in one lookup; "action" alone cannot say what is being written.- The interface returns scope; the host enforces the record.
assignedandownare joins against school data — enrollments, guardian links, class rosters — which the access layer will never own. - Credentials live here, sessions do not.
Authenticateanswers "are these the right credentials"; the host still issues and validates the cookie, so session loading never calls the interface.
Cache semantics
- One process-local
map[string]entrybehind async.RWMutex. No Redis, no cross-replica coordination. - Absolute TTL (30 s):
expiresAtis set when the entry is loaded and never refreshed by a read. A sliding TTL would let an active user keep a revoked permission indefinitely. - Invalidation is the correctness mechanism; the TTL is only a backstop. Any write that changes permissions or a user — role save, user create/edit/disable, password change — invalidates, so the same process sees a revocation on the next request.
- Staleness across replicas is accepted: a second replica may serve a stale decision for up to the TTL. For a school install that is not worth a shared cache.
- Fail closed: an internal error returns
Allowed=false, never allowed.
Boot behaviour — core vs additional
Plugins declare whether they are core or additional via plugin.Core() (nx-common
v0.3.0, APIVersion 3), which decides recovery.
| failure | core (access, users) | additional (register, later modules) |
|---|---|---|
Register |
refuse to start | refuse to start |
Migrate |
refuse to start | log, disable the feature, keep serving |
Init |
refuse to start | log, hide the feature — no routes, no settings tab |
The question is not how badly it failed but whether the app can still do its job: without access nobody can log in; without the register a school can still run students, attendance and fees.
What the split costs
users.id is referenced across the host: teachers, guardian links, audit rows,
payment created_by, sessions. Moving users out turns those into plain string
references owned by another component:
- no FK integrity between host tables and users,
- the host can no longer
JOIN users— teacher lists, guardian links and audit "who did it" need the access API (UsersByRole,DisplayName), - the session should therefore carry the role code, so a session load costs nothing
and only
Canconsults the access layer.
That last point is the decision to make before any extraction, not after.
Rendered from plugins/access/access.md · OKF bundle · regenerate with npm run render