type
Contract
title
Access — users, roles and permissions
description
The users/roles/permissions interface: Authenticate, Can(user, resource, action) returning a scope, cache with absolute TTL and invalidation, fail closed.
tags
plugin, rbac, access, contract, cache
status
stable
stale_after
Fri Apr 30 2027 02:00:00 GMT+0200 (Central European Summer Time)
generated
day-to-day-task/deepseek-v4.1-flash · Fri Oct 30 2026 12:25:00 GMT+0100 (Central European Standard Time)
verified
process:live-acceptance-run · Fri Oct 30 2026 12:25:00 GMT+0100 (Central European Standard Time)
sources
repo → https://github.com/nextrum-sy/simple-lms

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.

flowchart TD subgraph host["Host"] req["request"] mw["Require(resource, action)"] record["record-level scope<br/>canAccessStudent · teacher rosters · guardian links"] session["session cookie<br/>issued and validated here"] end subgraph access["Access interface"] auth["Authenticate(username, password)<br/>credentials only"] can["Can(userID, resource, action)<br/>→ Decision{Allowed, Scope}"] cache["in-memory cache<br/>userID → permissions<br/>absolute TTL 30s"] inv["InvalidateUser / InvalidateRole / InvalidateAll"] end defs["rbac.DefaultRoles()<br/>rbac.Catalogue()"] -->|"handed in at boot"| prov["RoleProvisioner<br/>SyncDefaults"] prov --> store[("roles · users")] cache -->|miss| store store --> cache req --> session --> mw --> can --> cache mw -->|"403 when denied"| req mw -->|allowed| record auth --> store inv --> cache

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:

  1. resource is part of the check. A cache key of userID → {resource → {read, write, delete, scope}} answers every gate in one lookup; "action" alone cannot say what is being written.
  2. The interface returns scope; the host enforces the record. assigned and own are joins against school data — enrollments, guardian links, class rosters — which the access layer will never own.
  3. Credentials live here, sessions do not. Authenticate answers "are these the right credentials"; the host still issues and validates the cookie, so session loading never calls the interface.

Cache semantics

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:

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