ADR-0037: Scoped role bindings for org units and rings
ADR-0037: Scoped role bindings for org units and rings
Section titled “ADR-0037: Scoped role bindings for org units and rings”- Status: accepted
- Date: 2026-10-31
Context and problem statement
Section titled “Context and problem statement”halo-server’s roles (ADR-0015) are global: an operator can kill any experiment, a viewer reads every device, an admin revokes any device. ADR-0015 says so: “Per-experiment or per-ring scoping is not modelled.” Units (ADR-0018) delegate ownership of policy to business units and teams, but the console has no unit-scoped roles, so a business unit’s on-call engineer who must see their own fleet and revoke their own lost laptop has to be a global viewer and a global admin. Delegated administration and multi-tenancy stop at the policy repo.
How does halo-server grant a role over part of the org without making the authorization surface harder to review, and without changing anything for the policies that exist today?
Decision drivers
Section titled “Decision drivers”- Existing policy and existing tests mean exactly what they meant:
identity.rolesandadminGroupsstay global, and a policy with no scoped binding authorizes every request as before. - Deny by default: a resource whose scope cannot be determined (unknown name, unprovisioned owner, policy not loaded) is out of every scope.
- One reviewable table: every route says how its resource is scoped, next to the permission it needs, and a test fails when a route has no entry.
- Scope comes from the server, never the client: rings and units are derived from policy and the server’s own records, never from a request body, a query parameter or a device’s report.
- Lists must not leak: out-of-scope items, and counts or aggregates computed from them, are not returned.
- The ADR-0036 and ADR-0030 guarantees extend to scopes: no token stronger than its creator, no approval counting for what its approver could not approve.
- No new dependency, no new storage, no new trust boundary.
Considered options
Section titled “Considered options”- Scoped bindings in
identity.roleBindings(halos.yaml), a route-to-resource table, conjunctive ring and unit scopes. Chosen. - Unit files declare their own console roles (
Unit.admins). Each unit’s owners approve their own unit file (ADR-0018 CODEOWNERS), so a unit owner could grant anyone authority over the unit without central review, and nested units could each re-delegate. The grant of authority is a security decision for the root owners; it stays inhalos.yaml. - Per-route group lists in policy. Rejected by ADR-0015 for the same reasons; scoping each one multiplies the problem.
- An external policy engine (OPA, Cedar). A new dependency and trust boundary; ADR-0007 already chose Go over Rego.
- Disjunctive scopes (a resource in any listed ring OR any listed unit). Matches
ChangeScopein ADR-0030, but a binding{rings: [canary], units: [payments]}would then cover all of canary and all of payments. Authority should narrow as a scope grows more specific, so dimensions intersect; two bindings express a union. - Scope at the route only (a binding names routes). Cannot express “this unit’s devices”, which is the case that motivated the change.
Decision outcome
Section titled “Decision outcome”Configuration
Section titled “Configuration”identity: adminGroups: [platform-admins] # global, as before roles: { viewer: [eng] } # global, as before roleBindings: - role: operator groups: [canary-oncall] scope: { rings: [ring1-canary] } - role: admin groups: [payments-platform] users: [lead@acme.example] scope: { units: [payments] } - role: viewer groups: [payments-sre] scope: { rings: [ring1-canary], units: [payments] }- A binding grants
roleto every listed group and user (user ids compared canonicalized, as everywhere else). An emptyscopeis global and means exactly what the same group underidentity.rolesmeans. scope.ringsandscope.unitsname rings and units in policy. A unit covers its descendants. Within a list any value matches; across the two lists both must.- Validation (
internal/policy/rolebinding.go,roleBindings): a known role, at least one group or user, no blank or space-padded names, every ring and unit defined, no duplicates. Errors sit underidentity, a security path: halo-server refuses such a policy and keeps the last good one. A scopedauditorbinding is a warning (audit routes are global only, so it grants nothing). The JSON schema matches. - Role bindings’ groups are kept in the session like role groups (
roleGroups). Grants are recomputed from current policy and the directory on every request, as roles are.
Grants and resources
Section titled “Grants and resources”policy.Identity.Grants(subject)returns every grant: the global ones fromadminGroups/roles, then each matching binding in file order, with its source (identity.roles.viewer,identity.roleBindings[3]).Principal.Bindingsholds them;Principal.Rolesis their distinct role names, so/me,/capabilities, login audit entries and the console’s page hiding read as before.Principal.Admin(kept for pre-ADR-0015 clients) is true only for a global admin grant; a scoped admin shows inbindingsonly.- A resource (
policy.Resource) is the rings and units whose members something affects.RoleScope.Coversholds when the resource lies wholly inside the scope: every ring of the resource is a scope ring, and the resource’s unit chain contains a scope unit. A bounded dimension never covers a resource that is unbounded or unknown in it.
| Resource | Rings | Units |
|---|---|---|
| Experiment | its rings |
every unit (ring members come from any unit, ADR-0018) |
| Toggle | the rings its rules select; every ring if default: true or any rule has no ring condition |
every unit |
| Rollout (read, and its approvals) | every ring any step exposes | every unit |
| Rollout step approval (deciding) | the rings the next step enters (rollout.TargetResource, the target ADR-0030 already uses) |
every unit |
| Device | the owner’s ring, resolved now from policy and the groups the server holds (SCIM authoritative wins), never the ring the device reports | the owner’s unit chain |
Access request, ring-opt-in |
the requester’s ring when filed and the requested ring | the requester’s unit chain when filed |
Access request, mcp-server |
Org.ProfileReach of the requester’s ring’s profile, which the approval edits: every ring whose profile is it or extends it at any depth, and every ring of each experiment with a variant on such a profile; an unresolvable chain counts as reached |
every unit |
| Access request, other kinds | every ring (no automatic change; reach unknown) | every unit |
| Service token | its scope, compared with ScopeWithin |
Consequence: a unit-only scope covers people-shaped resources (devices, their requests); rings, experiments, rollouts and toggles are org-wide and need a ring scope without units, or a global grant. This is deliberate: a unit admin cannot kill an experiment that also runs for other units’ members.
Enforcement
Section titled “Enforcement”routePermsis unchanged (one permission per route).routeScopes(internal/server/scope.go) gives every route one of:self(the caller’s own data),byPath(a resource function),filtered(a list the handler narrows),inHandler(body-derived or a handler lookup, with the reason), orglobal(with the reason it cannot be scoped).TestRouteScopesCoverPermsrequires an entry for every routed permission and a reason for every global or in-handler one.s.routefirst checksp.can(need)(some grant holds the permission: ADR-0015’s gate, its denial unchanged), thenauthorizeScope. A global grant for the permission passes without resolving the resource: byte-for-byte the old behaviour, including 404 for unknown names. Otherwise:byPathresolves the resource from current policy and server records and passes when a grant covers it; an error (unknown name, unprovisioned owner, policy not loaded) is a 403, the same 403 as out of scope, so a scoped caller cannot probe names outside their scope.globalrefuses every scoped caller. A route missing fromrouteScopesrefuses every scoped caller.- Global-only routes: policy snapshot, budgets, cloud agents and their kill, audit log and session ledger (one hash chain; separation of duties keeps its review whole), session ingest, credential revocations, per-user session revocation (a user id has no server-side ring or unit), toggle proposals (the proposed rules may target anything), the incident freeze, break-glass unlock, and
whoamifor another user (the subject’s groups come from the query, so they cannot bound a scope). - One policy snapshot per request.
authorizeScopetakes the policy once and pins it on the request (withPolicy); the scope check and the handler read it (reqPolicy), so a reload between them cannot let a handler act on a resource the check never saw. The snapshot holds the org and the view rendered from the same load. Used by kill and unkill (experiments and toggles), rollout and access-request approvals and decisions (including the PR the decision opens), experiment status, the experiment and rollout views (list and one), the toggle view, the list filters, and service-token create, list and revoke (scope names, the admin grant and the role ceiling are checked against one load). Releases and freeze windows still read the current policy for display (they render registry and clock state, filtered by the pinned grants), so a reload there can show a newer ring list, never authorize an action. - Toggle preview.
GET /api/v1/toggles/{name}?user=evaluates the toggle for a subject. A reader without a global read grant may preview only themselves (with their own groups, not the query’s) or a subject in a ring they read (ringgiven). An unknown ring answers with the same 403 as an out-of-scope one, and a ring the reader does not cover is not named in the subject or the rule trace. - Lists: fleet (device reports placed by the server; fleet-token reports have no owner and are global-only), devices, releases (in-scope rings; convergence counted from visible hosts), experiments, rollouts, toggles, kill list (a kill of a name no longer in policy has no scope), freeze windows per ring (the incident freeze is shown to every reader: it holds every ring), access requests, service tokens. Totals and per-ring stats are computed from what is returned.
Interactions
Section titled “Interactions”- Service tokens (ADR-0036).
POST /api/v1/service-tokenstakesscope. The whole body is decoded strictly: exact keys (a case variant such asScopeis refused, thoughencoding/jsonwould accept it), each key once (encoding/jsonkeeps the last), no unknown keys, and ascopethat is present must name a ring or a unit ({},nulland empty lists are refused, never global). Rings and units are checked against policy. The creator must hold an admin grant whose scope holds the token’s (ScopeWithin: rings a subset, each unit equal to or below a creator unit), and each permission of the token’s role over a scope that holds it (grantWithin, whichroleWithinnow calls with the global scope). A scoped admin lists and revokes only tokens within their admin grants; an unknown id answers as out of scope. The principal holds exactly one grant{role, scope, "service-token"}. The record also keepscreatorScope, the scope of the creator’s admin grant that allowed it; on every use the token’s scope must still be within it under current policy (ScopeWithin), so a later policy that moves a unit out from under the creator’s unit refuses the token (403, audited asauthz.denywith both scopes).halo service-token create --scope-*compares the scope the server returns with the one requested; a server that ignored it (one from before this ADR mints a global token) gets the token revoked at once and an error, in every output mode. - Mixed versions. A binary from before this ADR ignores unknown JSON fields. A scoped token’s record therefore stores its role in
scopedRoleand leavesroleempty, so an older replica finds no role and refuses the token rather than treating it as global; a record with both, or a scope withoutscopedRole, grants nothing. Likewise a scoped approval stores the approver’s groups inscopedGroups, so an older replica counts it for no rule. An older binary also refuses a policy withroleBindings(strict decoding) and keeps its last good policy.rollout.ApprovalKeynow includes the next step’s rings and the backing experiment’s rings as two separate sorted lists (one merged set would keep the key when a step moves between two of the experiment’s rings), so an approval stops counting when either is retargeted, on every binary and client that reads approvals by key (older replicas,halo rolloutandhalo mcp serve, which fetch them from halo-server). The key’s shape changed, so every rollout-step approval recorded before the upgrade stops counting, for every rollout, and must be given again. - Minimum versions. Before adding the first
roleBindingsentry, run the first release with this ADR for every halo-server replica (scoped tokens, approvals and the policy itself), thehaloCLI andhalo mcp serve(they evaluate rollout gates locally from approvals: an older one counts a scoped approval toward a plain approval gate) and any standalonehalo controller run. - Approvals and quorums (ADR-0030). Approving needs an approve grant covering what the decision changes (route check). Each approval records the scopes of the approver’s scoped approve grants (
Approval.Scopes, nil for a global approver, which every approval before this ADR was).Org.QuorumFor(res, ...)counts a scoped approval only when one of its scopes coversres;Quorum(no resource) counts none. The rollout controller (rollout.Governance.Apply), the approvals view and access-request decisions all pass the resource, so an approval recorded for a step whose rings are later edited stops counting; a scoped approval that does not cover the step satisfies no plain approval gate either, nor does the requester’s own approval. Approval rules (ADR-0030) select an access request by the same reach: an opt-in adds the requested ring, an MCP server request every ring ofProfileReachand every unit. - Audit. Every 403 from a scope check is
authz.denywithscope(global,unknown,out of scope),resourcewhen known, and the caller’sbindings. Every entry a handler writes for an action a scoped grant authorized carriesauthzRole,authzScopeandauthzBinding. An action authorized by a global grant adds nothing, so its entry reads exactly as before this ADR and as every older entry: absent means global. - Break-glass stays one global admin grant: it exists for when policy or the IdP is broken, and scoping it to policy would defeat that.
- Dev mode.
--dev-insecure-rolesgrants global roles, as before.
Clients
Section titled “Clients”GET /api/v1/meandGET /api/v1/capabilitiesreturnbindings([{role, scope, source}]).halo whoami --user U --groups Gprints the console roles and scopes the user holds under the policy (consoleRolesin JSON).halo service-token create --scope-ring R --scope-unit Umints a scoped token;listshows each token’s scope.- The console (
web/) still hides pages by permission and shows nothing about scope; the server filters and refuses regardless. Showing scopes in the console is a follow-up.
Threat model
Section titled “Threat model”| Threat | Mitigation | Residual |
|---|---|---|
| Scope confusion through a body or query field (a ring in the body that differs from the resource’s) | Resources are derived from the path’s server-side record and current policy only; decision bodies carry no scope; halo whoami for another user, whose groups come from the query, is global only |
|
| A device reports a ring it is not in to become visible to, or hidden from, a ring-scoped reader | Devices are placed by the server from the owner’s identity (deviceResource); the reported ring is display data |
|
| Probing for names outside one’s scope | Unknown and out-of-scope resources answer the same 403 body; lists never include them; counts are recomputed | A scoped caller learns that a name is “not theirs”, not whether it exists |
| Unit admin acts on an org-wide object (kills an experiment running for other units) | Experiments, toggles, rings and rollouts are every-unit resources: a unit scope never covers them | Units cannot own experiments; that needs a policy model change |
| Approving a change wider than the approver’s scope (an opt-in from another ring, an MCP server added to a profile other profiles extend or experiment variants use) | Request resources model what approving changes, from the server’s record: both rings for opt-in, ProfileReach (the profile’s rings, the rings of every profile extending it, the rings of experiments with a variant on any of them; unresolvable chains count) for MCP, every ring for kinds with no automatic change. Approval rules use the same reach |
Reach is computed from the policy at decision time: a profile that starts extending the edited one later is not re-judged |
| Toggle preview used to evaluate subjects outside one’s scope, or as a ring-name oracle | Scoped readers preview themselves or a subject in a ring they read; unknown rings answer as out of scope; uncovered rings are redacted from the subject and the trace | |
| Policy reload between the scope check and the action | One snapshot (org and view) per request for kill, unkill, approvals, decisions, status PRs, experiment, rollout and toggle views and filters | Releases and freeze views read the newest policy for display |
| A ring or unit deleted and recreated under the same name | Scopes, token scopes and creator scopes compare names, so the recreated ring or unit is still within them. Treat a name as a stable identity: retire a name instead of reusing it, or revoke the tokens and approvals that name it | |
| A scoped approval counted after the step it approved is retargeted | Approvals record scopes; quorums and the plain gate count them only while they cover the step; ApprovalKey includes the step’s and the experiment’s rings, so any retargeting starts a new key, for older binaries and local CLI/MCP evaluation too |
Pending approvals are lost once at upgrade (new key) |
| Token minted wider than its creator | adminGrantOver and grantWithin at creation; strict body decoding (exact keys, once, no empty scope); unknown rings and units refused; creatorScope re-checked against current policy on every use |
A creator who later loses their binding leaves their tokens in force until expiry or revocation (the token is checked against the grant’s recorded scope, not the creator’s current bindings, which a token cannot see) |
| A server that predates scopes mints a global token for a scoped request | halo service-token create compares the returned scope, revokes on mismatch and fails |
A client other than halo must check serviceToken.scope itself |
| An older replica reads a scoped token or approval as global | scopedRole / scopedGroups fields older binaries do not read (tested against the old record shape) |
An older replica counts a scoped approval toward a step’s plain approval gate (no rule); upgrade every replica before adding the first binding |
| Policy typo grants too much (misspelt unit) or too little silently | Unknown rings and units, unknown roles and empty bindings are validation errors on a security path: the server keeps the last good policy | |
| A route added later without thinking about scope | TestRouteScopesCoverPerms fails; at runtime a route with no routeScopes entry refuses every scoped caller |
|
| Audit cannot tell what authorized an action | authzRole/authzScope/authzBinding on every entry a scoped grant authorized; authz.deny names why |
Entries from global grants carry nothing new (absent = global) |
Consequences
Section titled “Consequences”- Good. Delegated administration reaches the console: a unit admin sees and revokes their members’ devices, a ring operator kills that ring’s experiments and toggles, a ring approver approves that ring’s rollout steps and counts toward its quorum, with tokens to automate exactly that.
- Good. Additive: no existing route, role, policy, token, approval or audit entry changes meaning.
TestRolePermissionMatrixand every other existing test pass unchanged;TestUnscopedBindingMatchesRoleschecks an unscoped binding against the same role inidentity.roleson every route. - Bad. Scoped callers resolve resources per request (a policy lookup, a device and directory lookup for device routes); list filtering is O(items). Human-scale data; global callers skip it.
- Bad. Unit scopes do not reach org-wide objects, and the audit log, budgets and policy are global only. A unit that needs those needs a global grant.
- Neutral. The console shows no scope yet; a scoped user sees pages whose lists are filtered and whose buttons may 403.
Implementation status (2026-10-31)
Section titled “Implementation status (2026-10-31)”| Commitment | Code | Test | Status |
|---|---|---|---|
identity.roleBindings, validation, schema, grants |
internal/policy/rolebinding.go, types.go, identity.go, schemas/halos.schema.json |
internal/policy/rolebinding_test.go (TestGrants, TestRoleBindingValidation) |
Done |
| Covers, ScopeWithin | internal/policy/rolebinding.go |
TestRoleScopeCovers, TestScopeWithin |
Done |
| Route scope table, enforcement, list filters, deny audit | internal/server/scope.go, rbac.go, handlers |
internal/server/scope_test.go (TestRouteScopesCoverPerms, TestScopedAuthorizationMatrix, TestScopedListFiltering, TestUnscopedBindingMatchesRoles) |
Done |
| Request resources, scoped approvals, QuorumFor | internal/server/scope.go, governance.go, internal/policy/governance.go, internal/rollout/governance.go |
TestScopedRequests, TestScopedQuorum, TestQuorumScoped, TestGovernanceApply |
Done |
| Token scope, ceiling, management, skew-safe record | internal/server/servicetokens.go |
TestScopedServiceTokens, TestScopedRoleCeiling, TestScopedTokenRecordSkew |
Done |
Security review (2026-10-31): profile reach for MCP requests and approval rules; strict token body; CLI revokes an unscoped token; toggle preview; creator scope re-checked on use; ApprovalKey rings; approvals read as the rollout; global-only admin; requester excluded from the plain gate; one policy snapshot per request |
internal/policy/rolebinding.go (ProfileReach), internal/server/scope.go, governance.go, servicetokens.go, toggles.go, auth.go, internal/rollout/governance.go, cmd/halo/cmd_servicetoken.go |
internal/server/scope_review_test.go, TestProfileReach, TestApprovalKeyChangesWithStepAndChange, TestGovernanceApply, TestScopedServiceTokens, TestServiceTokenCLIScopeIgnored, TestOpenAPILiveConformance (scoped pass) |
Done |
| Audit fields | internal/server/scope.go (withAuthz, authzDetails), audit.go |
TestScopedAuditFields |
Done |
| CLI | cmd/halo/cmd_policy.go, cmd_servicetoken.go |
TestWhoamiConsoleRoles, TestServiceTokenCLIScope |
Done |
| Console shows scopes | web/ |
Follow-up |
Mutation check: each of 15 single-line mutations (scope ignored by Covers, the path check, list filters, the token ceiling, quorum counting, audit fields, global-only routes, request resources, the token record, token management, strict decoding, binding scopes, unit chains, approval scopes, device placement) makes at least one of these tests fail. The security review’s fixes were checked the same way: 18 mutations (profile reach without extends or variants, the approval-rule target, repeated, case-variant and unknown keys, empty scopes, the CLI scope check, the toggle preview’s self, groups and redaction rules, the creator-scope check and record, ApprovalKey rings, approvals scoping, admin, the requester’s own approval, and both halves of the policy snapshot) each fail a test.
More information
Section titled “More information”- Delegated administration, Security model, API reference.
- ADR-0015 (roles), ADR-0018 (units), ADR-0030 (approval quorums), ADR-0036 (service tokens, break-glass).