Appearance
Permission Model
Concept layer — frozen. The Permission Model generic. Nothing here is written by a normal playbook run; a project's own feature analysis is the live document and takes every edit. This layer names no project and links to none — the dependency runs one way, from an application to its concept.
Business Context
Any system with more than one kind of user eventually needs one stated answer to who may do what. Answered ad hoc — a role column here, an "is admin" flag there, a hidden menu item somewhere else — the answer ends up scattered across screens and endpoints, impossible to audit and unsafe to change. This concept puts it in one place: a catalog of the things that can be done, named bundles of decisions about them, and an assignment of those bundles to the actors who hold them.
Business-Level Definition
Administrators define reusable bundles of permissions and assign them to actors — a person, or a named set of people — so that access is granted by role rather than by hand-editing per-person flags. A bundle carries explicit denials as well as grants, so an exception ("everyone in that team except this one person") can be expressed without taking the bundle apart.
Decisions govern two surfaces, and the concept insists on both:
- what the client displays or enables, and
- what the server permits.
The client's copy is a courtesy — it keeps people from being offered actions that would fail. The server's is the control. A project that enforces on one side only has either an unusable interface or no access control at all; there is no reading of this concept in which the client is authoritative.
Requirements Definition
- Name every protected capability once, in a catalog, and reference it from exactly one place on each side of the wire.
- Bundle those names into reusable, administrable sets rather than granting them one at a time.
- Assign a set to a person or to a group, within a scope or across all of them.
- Decide every request the same way, through one evaluation path, defaulting to refusal.
- Make a decision explicable: an administrator must be able to ask why an actor was allowed or refused and get the deciding rule back.
- Record who changed a definition and who was granted or revoked what.
Technical Context
User Stories / Use Cases
- As an administrator, I create a bundle and choose the capabilities it grants or denies.
- As an administrator, I browse a filterable catalog of capabilities, each with a readable title and a description, and add them to a bundle.
- As an administrator, I assign a bundle to a person, or to a group, and choose where it applies.
- As an administrator, I ask whether a given actor may perform a given capability in a given scope, and see which rule decided it.
- As a developer, I protect a new endpoint by naming its capability and nothing else — enforcement is not written per endpoint.
Functional Requirements
The capability catalog
- Every protected capability has one code, unique across the system. A code names a capability, not a screen and not a table.
- Each entry carries the code, a grouping value so the administration UI can filter, and keys for a translated title and description. The catalog holds no prose of its own — display text belongs to the localisation capability, keyed by the code.
- The catalog cannot be allowed to drift from the handlers, and it is not editable at runtime. That is the property; there are three ways to get it, and a project states which it chose.
- Derived at startup. Each protected handler declares its code as metadata and the catalog is composed by reading those declarations, plus a small explicit list for capabilities that belong to no handler. A new handler is catalogued for free; nothing can be added or renamed at runtime; and the catalog can carry only what a handler can declare about itself.
- Hand-written, reconciled by a test. One literal registry — code, module, method, path, a summary, and any per-entry fact the handler cannot know (which client surfaces use it, whether a scoped grant may ever satisfy it). A build-time test compares it with the routes and fails on a handler without an entry, an entry without a handler, a stale path, or a code claimed twice. Same property, one place to read, at the price of duplicating method and path and of a test that must stay strict.
- Stored in a table. Buys runtime editability and inherits the drift — codes that name nothing, and handlers that name codes nobody has created. Whichever is chosen, the standalone capabilities — a UI control with no endpoint behind it — need an explicit list; only the second option keeps that list in the same place as everything else.
Permission sets
- A permission set is a named, administrable bundle of rules, with a description, an active flag, and a soft-delete marker.
- A rule is a triple: the code it concerns, an effect (allow or deny), and a breadth.
- Rules validate on write: the code must exist in the catalog, effect and breadth must be legal values, and two rules for the same code and breadth in one set are rejected as a duplicate rather than being silently collapsed.
- Whether set names are unique, and within what — globally, or per scope — follows from the scope binding decision below; a project must state it either way.
Breadth
Breadth is how far a rule reaches into the data, independently of whether it allows or denies:
| Breadth | Reaches |
|---|---|
| every record | all records the capability applies to |
| own records | only records the actor is the owner of |
| a named record | one identified record |
A project may implement a subset — the narrowest breadths cost the most to evaluate, since they need the record in hand rather than only the actor. Two rules follow, and the second is the one that gets missed.
Do not accept a breadth you do not implement. An unimplemented value is rejected on write, and on evaluation a rule carrying one is treated as absent, and logged — never silently widened to the broadest breadth, and never allowed to fail the evaluation. Silently widening turns a narrowing rule into a granting one. Failing the evaluation is the opposite mistake with the same root: where a cache miss evaluates every capability at once, one unsupported rule in any set the actor holds then denies that actor everything, and a rule that can only arrive through a seed or a migration becomes a lockout nobody can explain from the interface.
A breadth is implemented only when enforcement consults it. Accepting a value on write and ranking it in evaluation is not implementing it. If the winning rule says "own records" and the handler that runs afterwards checks only "is this capability in the actor's allowed set", the breadth has been widened just as surely as if it had never been validated — the narrowing rule grants everything the broad one would, and nothing in the response reveals it. Each breadth a project accepts names the handlers, or the shared predicate, that read it; a breadth nothing reads is rejected until something does.
Assignments
- A set is assigned to a person directly, or to a group, whose members inherit it.
- Group inheritance is what makes the model administrable: access follows the job rather than the jobholder, and a change of staff is a membership edit rather than a sweep through assignments.
- An assignment is a bare link — set, actor, scope. It carries no effect of its own: see Deny lives in one layer only.
- How an assignment is bound to a scope is a decision, not a given: see Binding an assignment to a scope.
Evaluating a request
To decide whether actor A may perform capability C in scope S:
- Gather the assignments that apply: A's own assignments in S, A's assignments that are global, and the same two for every group A belongs to.
- Take the referenced sets, discarding any that is inactive or deleted, and collect from them every rule naming C.
- Order the candidates and take the winner — see Rule precedence.
- If no rule names C at all, refuse. Default deny is not a fallback; it is the base case.
Steps 1 and 2 are pure reads and should be batched, not walked one assignment at a time.
One exception to default deny, and only one. A small, closed set of bootstrap capabilities — who am I, which scopes may I reach, sync my identity — may be granted to every authenticated actor without evaluation. They are what a client needs before it can obtain the grants that everything else is evaluated against, and requiring a grant for them is circular — a "bootstrap" bundle assigned to everyone is the usual first attempt, and it is the same exception with an extra moving part. The set is listed in code in one place, catalogued like every other code, contains nothing that is scoped to data, and grows only by a recorded decision.
Enforcement on both sides
- Every protected server handler runs the same evaluation. There is one enforcement path; a handler that checks access its own way is outside the model and will not be found by anyone auditing it.
- The client is given the actor's decided capabilities and hides or disables what they cannot use. Handing the client the rules instead of the decisions would make it re-implement precedence, which is precisely where the two sides would drift apart.
- The client's copy is delivered with whatever payload already establishes the session, not fetched separately, so no screen renders before it knows what to render.
Diagnostics
A read that answers "may this actor do this, here?" and, on request, explains itself by returning the winning rule and the set it came from. Without it, a misconfigured bundle is debugged by reasoning about precedence in one's head, which is exactly what nobody does correctly.
Auditing
- Changes to a set's definition and changes to who holds it both belong in the system's shared append-only audit trail, filed against the set. This concept keeps no audit store of its own.
- The entry is written in the same transaction as the change, so it exists if and only if the change committed.
- An assignment entry names the assignee as its subject — the person or group — so the question "what was this person granted, and by whom" is answerable without scanning every set.
- Which scope an assignment entry carries is a decision with a visible consequence. If entries are filed globally, the administrators of the scope whose access just changed cannot see the change through their own scoped view of the trail. Filing the entry against the scope the assignment applies to keeps it visible where it matters — even when the set itself is global, and even if the actor record carries no scope of its own. This works because that scope is a stored attribute of the assignment: the assignment names the scope it applies in, so the entry records a fact the change itself carried. Where a record's scope is instead inferred per query, the same move would bake today's inference rule into an append-only record, and Users & Groups forbids it for that reason. Stored scope, file against it; derived scope, file at system level.
- Recorded context should carry display names as they read at the time. An audit entry is a point-in-time record; renaming a set later must not rewrite last year's history.
Non-Functional Requirements
- Evaluation is on the hot path. It runs on every protected request, so it must be a cache read in the common case and a small number of batched queries otherwise.
- Writes are atomic. Creating or updating a set together with its rules, and creating or removing an assignment, each commit as one unit with the audit entry that records them.
- Decisions are consistent. Two evaluations of the same question, against unchanged data, return the same answer — which is a constraint on precedence being total, not merely defined.
Domain Model
- Permission set — name, description, active flag, and its rules. Storing rules as a structured document on the set keeps a set's meaning in one row and one write; a separate rules table buys queryability ("which sets grant this code?") at the cost of that atomicity. Either is defensible; the concept assumes the former and a project that chooses otherwise should say why.
- Assignment to a person and assignment to a group — two relations rather than one polymorphic one, because the two are read differently: one is looked up by actor, the other by actor's groups.
- Group membership is not owned here. This concept consumes it and must invalidate on it; Users & Groups owns and audits it.
- The catalog is not an entity. It has no table, no identifiers and no lifecycle.
API Analysis (API-A)
See child page: Permission Model — API Analysis (API-A).
Logging & Monitoring
.info
- on creating, updating or deleting a set, and on creating or removing an assignment.
.warn
- on a rule referencing a code absent from the catalog, which means a capability was renamed or removed without its rules being migrated.
Refusals are not logged individually — they are the normal operation of a default-deny system. What is worth watching is the rate of refusals per capability: a spike is either an attack or a botched assignment, and both want investigating.
Caching & Performance
- Cache the decided capabilities per actor and scope, in a store shared by every replica, with a bounded lifetime.
- Invalidate on five triggers, not three. An assignment created or removed; a set's rules or active flag changed; a group's membership changed; the actor deactivated or deleted; a group that holds grants deactivated or deleted. The last three are the ones that get forgotten, because none of them looks like a permission change — each is a lifecycle act in another capability — and every one of them is an access-removal act. Forgetting any of them means access outlives its revocation for the lifetime of the cache.
- The scope's own lifecycle is either a sixth trigger or a check the guard makes on every request (is the scope still active?). Either is defensible; a project says which, because a deactivated scope whose grants stay cached and unchecked is still open.
- Invalidating on membership and on group lifecycle means Users & Groups must reach this cache, or this one must observe those changes. Whichever way round, it is a dependency to write down rather than to discover in production.
- Cache the decisions, never the rules. A cache of rules leaves precedence to be applied by each reader, and readers disagree.
Rule precedence — the decision most readers get wrong
Two rules can name the same capability and disagree. Something has to order them, and there are two defensible orderings that give different answers to the same question.
| Ordering | Sorts by | A narrow allow against a broad deny |
|---|---|---|
| Specificity first | breadth, then effect | the allow wins — the narrower rule is the more specific statement |
| Deny first | effect, then breadth | the deny wins — a denial is absolute wherever it reaches |
Under specificity first, deny-overrides-allow still holds, but only within one breadth: a deny beats an allow of equal reach, and loses to one of narrower reach. Read it as "the most specific statement about this actor and this data wins, and where two statements are equally specific, the refusal wins".
Under deny first, a deny anywhere in any set the actor holds ends the question. This is what most readers assume, because it is what network ACLs and most policy languages do, and because "deny overrides allow" is usually quoted without the qualifier.
Neither is wrong, and the trade-off is symmetric:
- Specificity first makes exceptions expressible in the natural direction — deny broadly, then carve out. It also means a broad deny is not a guarantee, which is a genuinely surprising property for an administrator writing one, and a poor fit for a project where a denial must be a hard stop for compliance reasons.
- Deny first makes a deny a hard stop, which is easy to reason about and easy to audit. The price is that a deny can never be carved out: to grant one person an exception, the deny must be removed from the bundle and re-expressed for everyone else, which is how bundles fragment.
A project must state which it chose, in those words. The two orderings agree on every case except the one that matters, so a document that describes precedence as "deny overrides allow" without saying whether breadth is compared first has not specified the model — it has only specified half of it, and readers will fill in the other half with their assumption.
Binding an assignment to a scope
An assignment applies somewhere. Two shapes express that, and the choice is not cosmetic.
| Nullable scope column on the assignment | Join table of assignment-to-scope | |
|---|---|---|
| One assignment in several scopes | not expressible — create one assignment per scope | one assignment, several rows |
| "Everywhere" | the column is empty | a row convention, or the absence of rows |
| Evaluation query | one predicate: this scope, or empty | a join |
| Revoking in one scope | delete that assignment | delete that row |
| Reads as | n separate grants | one grant with a reach |
The nullable column is markedly simpler, and simplicity in the thing every request evaluates is worth a lot. Its cost is that "the same grant, in these four scopes" becomes four rows that nothing relates to each other: renaming, revoking or auditing them as one act is left to whoever is operating the UI. The join table keeps them one grant, at the price of a join on the hot path and an extra shape to keep consistent.
Two warnings, both learned the hard way:
- Empty meaning "everywhere" is a load-bearing convention, not a null. Every read must treat the scope-less assignment as applying in the scope being evaluated, and every write path must be deliberate about creating one. A project that lets the empty value mean "not filled in yet" anywhere else in its schema will eventually create a global grant by accident.
- Documents drift toward the join table even when the code has the column. The join table is the shape people design on a whiteboard, so it survives in older analyses after the build simplified it. If a project's own documents disagree about which shape it has, the disagreement is the finding — record which one is real and correct the others, rather than letting a reader pick.
Deny lives in one layer only
Deny could be written in two places: inside a set's rules, or on the assignment ("assign this set, but as a denial"). The concept puts it in one — the rules — and the reason is not taste.
With deny at both layers, evaluating a capability means ordering rules by breadth and effect, and then ordering assignments by effect too, with no natural relation between the two orderings. Every question of the form "does an assignment-level deny beat a rule-level allow of narrower breadth?" has an arbitrary answer that somebody must invent, document and then defend. Keeping deny in the rules leaves the assignment a bare link, so it has nothing to contradict.
The cost is that denying one person something their group grants requires giving them a set that denies it, rather than flagging the assignment. That is one more object to name, and worth it.
Legal Context
Access control is one of the places where an external obligation is most likely to exist and least likely to be noticed, because the mechanism works perfectly well whether or not anyone has checked. Nothing here can tell a project which of the following bind it. What it can say is that each is answered by configuration, process and review rather than by the mechanism, so an unanswered question looks exactly like an answered one from the inside.
- Does anything outside the system dictate who may hold which access? Where it does, that — not an administrator's convenience — sets the granularity of the catalog, and a catalog too coarse to express the required distinction cannot be fixed by assigning more carefully.
- Is there a pair of capabilities the same actor must not hold at once? Segregation of duties is the classic case. The model can express any bundle, including a prohibited combination, and nothing in evaluation detects it — default-deny does not catch this. If such a rule binds, decide what checks it and when: at definition, at assignment, or in a periodic review. The third catches violations only once they have happened.
- Must access be re-attested periodically? Recertification asks for a point-in-time statement of who holds what — a query against current state, where the trail answers how the state was reached. Decide which surface answers which before somebody assumes the change history can attest.
- How long must the record of a grant outlive the grant? Revoking removes the current state and leaves only the audit entry, so a retention obligation on access records attaches to the trail — whose own retention rule was very likely set for a different reason.
- Is the assignment record personal data where the system operates? If an erasure obligation applies, decide what happens to a person's assignments and to the entries naming them, and whether erasure means deletion or anonymisation: an anonymised trail can still be counted and reviewed, a deleted one cannot, and an append-only trail resists both.
- If emergency access is subject to control, what governs the break-glass bundle? Seeding an unassigned bundle that grants everything is recommended below on operational grounds; where a rule requires emergency access to be controlled, that recommendation acquires an obligation — who may assign it, on whose authority, and what makes its use visible rather than merely recorded.
- Do the answers differ per customer? Where scopes are distinct customers, industries or jurisdictions, one project may have to satisfy several of the above at once. Whether the model can express a per-scope obligation is a design question with a deadline, because retrofitting it means revisiting every bundle.
Where a project does not know whether one of these applies, write it down as an open question with a name against it. That is a materially better state than an assumption nobody recorded.
Cybersecurity Considerations
- Default deny, at the base of evaluation rather than as a catch-all at the end of a chain.
- Server-side enforcement is the control; the client's copy is presentation.
- The endpoints that manage sets and assignments are themselves the highest-value target in the system — an actor who can assign sets can grant themselves everything. Guard them with their own capabilities, and treat escalating a set to global reach as a distinct capability from editing a scoped one.
- Audit entries carry identifiers and display names, never credential material.
- A break-glass bundle granting everything is worth seeding and leaving unassigned, so that the recovery path from a misconfigured administrator bundle exists and is visible.
Risk Assessment
- Over-granting is the likeliest failure, and bundling is what mitigates it: a small number of well-named sets is reviewable, whereas per-person grants are not.
- Privilege escalation through the management endpoints — see above.
- Stale decisions after a revocation, for as long as the cache lives. Bounded lifetime plus explicit invalidation on all five triggers is the mitigation; a project should state the worst case in seconds, because "eventually" is not a security property.
- Evaluation cost growing with assignment count — batched reads and a decision cache.
Auditing, Reporting & Measurement
Three questions get asked of an access model, and the trail answers only two of them. What is recorded, and in which transaction, is under Functional Requirements → Auditing.
- Who may do this, right now? Not a trail question at all — it is evaluation run in reverse. A forward evaluation goes from an actor to a decision; a review needs the opposite, from a capability to the actors holding it. Nothing else in the concept requires that direction, so it has to be named as a read of its own or it degenerates into opening every set by hand — and it is the read that makes over-granting visible.
- What was this person granted, and by whom? Answered by the trail, and answered cheaply only because an assignment entry names the assignee as its subject. Without that, the same question is a scan of every set's history.
- Who could have done this last March? Reconstructible only if every input to a decision is in the trail: assignment changes, set definition changes, and group membership changes. Membership belongs to Users & Groups, which audits it on its own terms and may not audit it at all. This is the same omission that breaks cache invalidation, in a second disguise: forget membership and access outlives its revocation and the past becomes unreconstructible. If the directory keeps no history, question 3 has no answer.
Reading the trail. A permission set's own history is read by whoever may read the set; the cross-entity browse is a distinct, more privileged capability, and the rule belongs to the Audit Log concept. What this concept adds is that the scope an assignment entry carries determines who can see it at all: file it globally and the people responsible for reviewing that access are the ones the entry is hidden from.
What a review actually needs is current state, not history. A review reads: per actor, which sets they hold, in which scopes, and what those decide to; per set, who holds it; per capability, who has it. All three are queries against live data. The trail explains how the state got here and is the wrong instrument for certifying that it is right.
Measurements worth keeping, each derivable from the model as described: the refusal rate per capability, since individual refusals are deliberately not logged and a spike is either an attack or a botched assignment; actors per set, because a set held by exactly one person is a per-person grant in a bundle's clothes; assignments with global reach, a number small enough to read in full; rules naming a code absent from the catalog, whose count is the backlog of drift — and whose only route in is a seed or a migration, since the management API rejects them, so the check that finds them is a test or a startup pass over stored sets, never the write path; and worst-case decision staleness in seconds, where the number Risk Assessment asks for gets checked against the cache's actual lifetime rather than against its intent.
Three gaps, stated rather than mitigated away.
- Refusals are not individually recorded, by design, so "was this person ever refused something" has no answer. The trade is deliberate — in a default-deny system that would record mostly noise — but an investigation into attempted access has to work from operational logs, and only if those were kept.
- The diagnostic read explains a decision now. It evaluates against current sets, assignments and memberships, so it cannot explain a decision taken last week: the inputs have moved. A reader who assumes otherwise will reconstruct a past decision and get today's answer.
- Nothing detects a prohibited combination of capabilities. See Legal Context. Evaluation asks whether a rule permits an action, never whether an actor should hold two permissions at once, and no report in this list would surface it either.
Known instances
None recorded in this copy. Add this project's instance here as it adopts the concept — one line naming the feature that implements it. This is the only place a concept may name a project, and the template ships it empty so a fork inherits no other project's entries.