From df4700c59f862eeecbf4c021a9978d2bdbd785da Mon Sep 17 00:00:00 2001 From: "Beau Beauchamp, WebTigers" Date: Sun, 25 Oct 2026 10:19:24 -0400 Subject: [PATCH] =?UTF-8?q?ACL.md=20=C2=A71a:=20role=20scope=20as=20the=20?= =?UTF-8?q?standard,=20not=20folklore?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The role graph said what a role may DO. Nothing said how much of the install a role may do it TO — so that question has been answered ad hoc, per screen, by whoever was looking at the time. §1a now states it as the standard, with the tiers in one vocabulary (SELF / ORG / ORG TREE / APP-WIDE) and the cut line named: `user` is self-scoped, `manager` is their org, `supermanager` their org plus its children, and `admin`/`superadmin`/`developer` are APP-WIDE — administering orgs is the job. "Tenant admin" is not a role in this model; if you mean someone bounded to one org you mean manager or supermanager. Two things the section is careful about: - **The unit of scope is the ORG, and how many people live in an org is the APP DEVELOPER'S decision.** One user per org, a team in one org, a tree of orgs — all valid configurations of the same model, and the ACL behaves identically under each. The platform scopes by org; populating those orgs is an application concern and deliberately not specified here. - **It reconciles with §1 rather than contradicting it.** "The same person can be `admin` in one org and `viewer` in another" is about where a role is STORED — on the membership, so it can differ per org. It is not a claim about reach. "What conforming code looks like" is the part that makes this usable as a standard: scope is decided in the SERVICE from the caller's role and passed into the model finder (a finder that hard-codes `org_id = ` has silently decided no app-wide role exists); a tenant role is scoped automatically while an app-wide role is not scoped at all; and a missing org filter is a defect ONLY if a tenant-scoped role can actually reach that surface — read the acl.ini grant first. `Tiger_Model_Media::search()` is cited as the reference gate, since it already decides from role and org together. Also: the role descriptions in core/configs/acl.ini now name their scope in that same vocabulary, so the cut line is visible by reading down the list instead of inferred. `admin` said "Administrator" and `superadmin` said "Platform administrator", which is where the ambiguity lived. Description strings only — no behaviour, and nothing asserts them. AGENTS.md's Authorization section points at §1a before you write a finder. Unit suite green (1200 tests, 18318 assertions). Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_013vdyTeoDrGE4hKxt53TsE8 --- ACL.md | 56 ++++++++++++++++++++++++++++++++++++++++++++ AGENTS.md | 12 ++++++++-- core/configs/acl.ini | 14 +++++------ 3 files changed, 73 insertions(+), 9 deletions(-) diff --git a/ACL.md b/ACL.md index cf2a89f6..6736989a 100644 --- a/ACL.md +++ b/ACL.md @@ -32,6 +32,62 @@ It is one ACL. Every request is judged against the same rule-set. That's the thi --- +## 1a. Role SCOPE — the baseline + +The role graph says what a role may **do**. It does not say how much of the install a role may do it +**to**. That second question is this section, and it is the standard: code that disagrees with the +table below is wrong and should be changed. + +### The unit of scope is the ORG + +Tenancy is the `org`. A person reaches an org by holding a membership in it (`org_user`), and the +**role lives on that membership**, resolved live each request. + +**How many people live in an org is the app developer's decision, not the platform's.** One user +per org, a team sharing one org, a hierarchy of orgs with `parent_org_id` — all are valid +configurations of the same model, and the ACL behaves identically under each. The platform's job is +to scope by org correctly; populating those orgs is an application concern and belongs in the app's +own design, not here. + +### The tiers + +| Role | Scope | Reaches | +|---|---|---| +| `guest` | — | public resources only | +| `user` | **SELF** | their own rows, within the org(s) they belong to | +| `manager` | **ORG** | their own org | +| `supermanager` | **ORG TREE** | their org and its child orgs (`parent_org_id`) | +| `admin` | **APP-WIDE** | every org — administering orgs is the job | +| `superadmin` | **APP-WIDE** | every org, plus platform-level surfaces | +| `developer` | **APP-WIDE** | everything (the god role) | + +**The cut line is between `supermanager` and `admin`.** Below it a role is bound to a tenant; at and +above it a role administers the platform, orgs included. **"Tenant admin" is not a role in this +model** — if you mean someone bounded to one org, you mean `manager` or `supermanager`. + +This does not contradict §1's *"the same person can be `admin` in one org and `viewer` in another"*. +That sentence is about where a role is **stored** — on the membership, so it can differ per org. +It is not a claim about reach: membership is how a person *acquires* `admin`, and the role is not +fenced by the org that granted it. + +### What conforming code looks like + +1. **Scope is decided in the SERVICE, from the caller's role, and passed into the model finder.** + A finder that hard-codes `org_id = ` has silently decided that no app-wide role + exists. Give the finder an org-scope parameter and let the service choose it. +2. **A tenant-scoped role is scoped automatically; an app-wide role is not scoped at all** — not + "scoped with an opt-in". An admin screen listing every org's rows is the design working. +3. **A missing org filter is not a defect by itself.** It is a defect only if a tenant-scoped role + can actually reach that surface. Read the `acl.ini` grant before reaching for a predicate. +4. **`Tiger_Model_Media::search()` is the reference gate** — it takes the role and the org and + decides from both, rather than assuming one answer for every caller. + +Both failure directions are real: a finder with no scope where a tenant role can reach it leaks +across tenants, and a finder hard-scoped to the caller's org on an admin surface walls an app-wide +role out of the orgs it administers. Check the grant, then pick. + +--- + ## 2. The goal An app developer needs to author their **own** authorization — general app rules *and* per-tenant diff --git a/AGENTS.md b/AGENTS.md index b0949466..b49400d9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -334,8 +334,16 @@ geocode / reverse / IP behind adapters, normalized `Place` payload) and **`Tiger Deny-by-default. Access is data in `acl.ini` / `acl_*` tables, never a hardcoded role compare. A resource with no allow rule is denied. Role comes from `org_user` (role-on-membership) and -is resolved live each request. Module `configs/acl.ini` grants access to that module's -controllers/services. Two resource shapes: a **controller** dispatch gates on +is resolved live each request. + +**Know a role's SCOPE before you write a finder — [ACL.md §1a](ACL.md) is the standard.** +`user` is SELF-scoped, `manager` is ORG, `supermanager` is ORG TREE, and **`admin`/`superadmin`/ +`developer` are APP-WIDE** — they administer every org. So the org scope is the SERVICE's decision +from the caller's role, passed into the model finder; a finder that hard-codes +`org_id = ` has decided no app-wide role exists. And a missing org filter is only a +defect if a tenant-scoped role can actually reach that surface — read the `acl.ini` grant first. + +Module `configs/acl.ini` grants access to that module's controllers/services. Two resource shapes: a **controller** dispatch gates on `isAllowed($role, , )` (privilege = action); an **`/api` service** gates on `isAllowed($role, , )` (privilege = method). Declaring the rule is part of writing the call — see "Services (`/api`)". diff --git a/core/configs/acl.ini b/core/configs/acl.ini index 07482815..58b87655 100644 --- a/core/configs/acl.ini +++ b/core/configs/acl.ini @@ -16,31 +16,31 @@ ; An app adds/renames/re-parents its own roles via a module acl.ini or the ; acl_role table — this is just the default spine. acl.roles.guest.role = "guest" -acl.roles.guest.description = "Unauthenticated request" +acl.roles.guest.description = "Unauthenticated request — public resources only" acl.roles.user.role = "user" acl.roles.user.parent = "guest" -acl.roles.user.description = "Authenticated base user" +acl.roles.user.description = "Authenticated base user — SELF scope: their own rows" acl.roles.manager.role = "manager" acl.roles.manager.parent = "user" -acl.roles.manager.description= "Tenant-scope manager (manages within their own org)" +acl.roles.manager.description= "ORG scope — manages within their own org" acl.roles.supermanager.role = "supermanager" acl.roles.supermanager.parent = "manager" -acl.roles.supermanager.description = "Tenant + sub-tenant scope (their org and its child orgs, via parent_org_id)" +acl.roles.supermanager.description = "ORG TREE scope — their org and its child orgs (parent_org_id)" acl.roles.admin.role = "admin" acl.roles.admin.parent = "supermanager" -acl.roles.admin.description = "Administrator" +acl.roles.admin.description = "APP-WIDE — administers every org" acl.roles.superadmin.role = "superadmin" acl.roles.superadmin.parent = "admin" -acl.roles.superadmin.description = "Platform administrator" +acl.roles.superadmin.description = "APP-WIDE platform administrator — every org, plus platform surfaces" acl.roles.developer.role = "developer" acl.roles.developer.parent = "superadmin" -acl.roles.developer.description = "God role — allowed on every resource" +acl.roles.developer.description = "APP-WIDE god role — allowed on every resource" ; --- Resources (default-namespace public entry points) ---------------------- acl.resources.index.resource = "IndexController"