Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions AUTHORITY_CHECKING_PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,15 @@ round numbers undercount):
admission. #415 notes a shadow-mode patch exists against an older base and needs
a rebase — treat it as a starting point, not merge-ready.

> **Since this record was written (3 Nov 2026, #1296).** Workstream A shipped and
> then flipped: the classifier
> (`business.ClassifyCentralCoverage`, metric `saas.accounts.rpc_policy.coverage`)
> and the central floor (`enforceCentralPolicy`) both exist, and enforcement is
> now what an unset `RBAC_CENTRAL_ENFORCEMENT` selects — `shadow` is an explicit,
> boot-warned opt-out. The paragraph above and the "Shape of the fix" below are
> kept as the scoping record they were, not as current state; current state is
> [`AUTHZ.md`](AUTHZ.md) "The central floor beneath the handler gates".

**Shape of the fix (for #415).** A central interceptor that reads the *full*
`MethodPolicy` and, per call, classifies the outcome `ok` / `gap` /
`broadening` / `unsupported`, emitting an audit signal and **changing no
Expand Down
70 changes: 70 additions & 0 deletions AUTHZ.md
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,43 @@ bypass on sensitive ops, IP/key floods.
up the wrong row (e.g., `getWebhook(id)` without filtering by orgID).
That's L2 + L3.

### The central floor beneath the handler gates

Before any handler runs, the interceptors apply the same org-member/org-admin
tenant the descriptor declares, against the caller's *verified* organization
(`enforceCentralPolicy`, `business.CentralTenantEnforcement`). It is a redundant
floor: it is never stricter than the `require*` site that still runs after it,
and it resolves only the subset it can decide without reading the request body —
no declared permission, platform role, MFA requirement, team or owned-resource
binding, no global-scope escape (`globalScopeEscapeMethods`) and no
shape-authenticated method (`business.ShapeAuthenticated`, whose caller's
credential is *in* the request body, so admission has no verified organization to
resolve against). Everything else stays with the handler gates above.

The shape-authenticated exemption is the one that needs its backstop named,
because `Issue` declares an org tenant and is nonetheless outside the floor. A
cross-organisation call is refused by the adapter instead, and that is proven
against a live store in two harness cases: the shared suite's **"an installation
issue cannot name a different organization"**, and this repository's own **"a
module workload is issued only where it is installed, within its pin, until
uninstalled"**. The exemption's *extent* — exactly `Issue` and `GetDelegation`,
never a third method — is pinned by
`TestTheCentralFloorExemptsExactlyTheShapeAuthenticatedMethods`.

**It enforces by default.** `RBAC_CENTRAL_ENFORCEMENT` unset means enforce;
`shadow` is an explicit opt-out that classifies only, and the boot warns naming
it on every start; any other value refuses to boot. The coverage classification
that drove the rollout (`saas.accounts.rpc_policy.coverage`) keeps running, since
what it measures is the part this floor still cannot reach.

Refusals are counted on `saas.accounts.rpc_policy.denied`, labelled by `method`
and by the gate that refused (`reason`). An alerting rule targets the
collector's normalised spelling of it,
**`saas_accounts_rpc_policy_denied_total`**, not the dotted instrument name —
see [module/MEASUREMENT_RUNBOOKS.md](module/MEASUREMENT_RUNBOOKS.md) under
`authorization-denials`, which gives both spellings and why the distinction
matters.

## Layer 2 — Permissions (RBAC, business layer)

**Where:** `pkg/business/service.go:CheckPermission` + the
Expand Down Expand Up @@ -330,6 +367,39 @@ ROLE`) with its own explicit grants, which a request transaction has no way to
reach. Each entry is recorded (`recordControlPlane`), so the crossings are
countable rather than merely greppable.

### The daily archive, and what "sealed" currently means

With `audit-archive/AUDIT_ARCHIVE_URI` set, the host writes one object per
complete UTC day — the whole platform's audit log, every organisation plus the
control plane — to the composed object-storage service's own bucket, and
records a receipt for each archived day in the audit log itself: the day, the
object key, its SHA-256 digest, and the byte and event counts
(`saas.audit.archive_exported`). The host's daily sweep offers each day; a
leased job performs the export. Two independent guards make it idempotent per
day: the job's idempotency key is the day, and the write is create-if-absent, so
even a job that outlived its own record cannot replace an archived day's bytes.

**State plainly what this does and does not prove.**

- It does prove the audit trail has a copy outside the database it was written
to, and that a later run of this host cannot silently rewrite an archived day.
- It does prove a tampered object is *detectable*: the receipt's digest
describes exactly the bytes that were written, and the receipt is in the audit
log rather than only in the bucket.
- It does **not** prove the object is immutable. That needs an object-lock or
retention policy on the bucket, which belongs to the infrastructure that
provisions the bucket and not to this module — accounts names a key prefix and
never a bucket, precisely so it makes no claim about a bucket's policy. Until
that lock exists, the archive is tamper-**evident** and not
tamper-**proof**, and anyone with write access to the bucket can replace an
object; what they cannot do is make the replacement agree with the receipt.
- A day with no events is still archived, as an empty array. The absence of an
object means "not archived" and nothing else.

A day older than the sweep's catch-up window is not archived automatically; it
is `ExportAuditLog` for an operator to run. Unset, the key leaves the audit
log's only copy in this database, and the boot says so.

## Removing an organization member: deleted vs. rendered ineffective

Organization membership is the root of a member's authority in a tenant, and
Expand Down
1 change: 1 addition & 0 deletions CLAIM_INVENTORY.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ must not be inferred from a row here. See
| ~~`users` / `mfa_devices` / `notifications` / `oauth_state` / `refresh_tokens` are outside RLS, protected by `WHERE user_id = $1`~~ | MODULE.md skip-list and AUTHZ.md skip-list, before this change | the first three carry forced RLS; the last two do not exist | **corrected** | #541 |
| The documented relation inventory matches the executable one | module/DATABASE_AUTHORITY.md § Scope and RLS inventory | `TestDatabaseAuthorityScopeInventoryMatchesCode` (`module/tools`) fails on drift in either direction | implemented | #541 |
| `GetServiceInfo`'s RLS catalog (`serviceRLSTables`) reports the current protected set | `pkg/business/introspection.go` | **stale**: 25 hand-maintained entries against 38 tenant + 13 user relations, sourced "store migrations through 60" | known drift, filed | #556 |
| The daily audit archive is "sealed" | AUTHZ.md § The daily archive, `pkg/business/audit_archive.go` | the object is written create-if-absent and its SHA-256 digest is receipted in the audit log, so a replacement is **detectable**; the object is **not immutable** — that needs an object-lock/retention policy on the bucket, which belongs to the infrastructure that provisions it and does not exist yet. Read "sealed" as tamper-evident, never as WORM | **configured**, with the immutability half **planned** elsewhere | accounts |
| Scope-node role grants and installation identity ship | PLATFORM_REFERENCE.md §1.2 | `98_layered_access`, `112_installations`, `pkg/business/installations.go` | implemented | accounts |
| RLS table→migration adoption table | AUTHZ.md § RLS coverage | names `audit_export_configs`, dropped by store migration 102 | historical (labelled) | #541 |

Expand Down
25 changes: 17 additions & 8 deletions PLATFORM_REFERENCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,10 +86,13 @@ from the audited platform, so read the mapping carefully.
proto+codegen instead of JSON+symlink. [module/AUTHORIZATION_CATALOG.md](./module/AUTHORIZATION_CATALOG.md),
[module/METHOD_POLICY.md](./module/METHOD_POLICY.md).
- **Enforce-by-default, ✅:** the audit's headline mistake was `RBAC_MODE`
defaulting to shadow and *failing open*. The starter has **no shadow mode** —
it is deny-by-default, always-enforce. That is the audit's recommendation
already in force; do not add a fail-open shadow toggle without a boot-validated
enum.
defaulting to shadow and *failing open*. The starter is deny-by-default and
enforces by default: exposure, impersonation and API-key-scope admission have
no off switch at all, and the central org-tenant floor enforces unless a
deployment names `RBAC_CENTRAL_ENFORCEMENT=shadow`, which the boot warns about
by name on every start. The enum is boot-validated — an unknown value refuses
to start — so no typo can turn enforcement off. Do not add a switch that
weakens authorization without both of those properties.
- **Agent/machine principals, ✅:** `principals` table with kinds
`human`/`service`/`agent` (`pkg/business/principals.go`); agents are
human-owned (`publisher/name:version`); `SubjectKind_PRINCIPAL` is canonical.
Expand Down Expand Up @@ -293,8 +296,10 @@ as designed, not proven, until exercised end to end.
1. **One source of truth, generated into every consumer** — proto `MethodPolicy`
→ `authz-methods.json` → ext_authz + gateway + REST, drift-checked in CI. The
starter's analog of "one policy file, two runtimes".
2. **Enforce-by-default, deny-by-default** — no fail-open shadow mode; missing
policy fails generation. This is the audit's #1 mistake pre-avoided.
2. **Enforce-by-default, deny-by-default** — the one classify-only switch
(`RBAC_CENTRAL_ENFORCEMENT`) defaults to enforcing, is boot-validated, and
names itself in a boot warning when an operator opts out; missing policy
fails generation. This is the audit's #1 mistake pre-avoided.
3. **Structural CI gates over review discipline** — authz/audit coverage, RLS
coverage, permission no-broadening, header lockstep (§5).
4. **Two-token S2S** with attenuating, audience-bound, hash-chained actor
Expand All @@ -311,8 +316,12 @@ as designed, not proven, until exercised end to end.

Drawn from the audit's "what's bad" list, filtered to what applies here:

1. **Don't add a fail-open shadow mode.** If a migration ever needs one,
boot-validate the enum and refuse to start on unknown values.
1. **Don't add a fail-open shadow mode.** If a migration ever needs one, give
it the three properties `RBAC_CENTRAL_ENFORCEMENT` has: the strict reading is
the default an unset value selects, the enum is boot-validated so an unknown
value refuses to start, and the weak reading logs a warning naming itself at
every boot. An operator switch whose unset value is the permissive reading is
the same mistake under a different name.
2. **Make any background-automation kill switch per-tenant**, never
deployment-global (§1.5).
3. **Keep read-only/support authority server-enforced**, never cosmetic — the
Expand Down
1 change: 1 addition & 0 deletions configurations/local/audit-archive.env
2 changes: 1 addition & 1 deletion libraries/source-read-sdk/library.codefly.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ sources:
module: saas-starter
service: accounts
endpoint: authority
contract-digest: sha256:606e52170c2e55688ce163eb1d54c347f0d19ac40915d7fb6252f2034b787c0e
contract-digest: sha256:93d5116cf8512cf484201338d7b11d4fc123cf121d9dbebb96f0722defd8bade
services:
- ModuleCapabilitiesService
generated-by:
Expand Down
16 changes: 16 additions & 0 deletions module/AUDIT_METRICS.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,22 @@ must not join or JSON-encode the arrays into strings. Audit schema validation
warnings are advisory and do not by themselves reject an installation or drop
its audit record. Authorization and transaction failures remain separate.

## The daily archive is not a metric, and not immutable either

`audit-archive/AUDIT_ARCHIVE_URI` turns on a scheduled export of each complete
UTC day's audit log to the composed object-storage service's own bucket, with a
receipt per day in the audit log (`saas.audit.archive_exported`: the day, the
object key, its digest, the byte and event counts). It is not part of the
scoped-metric surface this document describes and nothing here reads it.

It is named here only to keep one claim from being made by omission: the archive
is tamper-**evident**, not tamper-**proof**. The write is create-if-absent and
the receipt carries the digest, so a replaced object disagrees with the trail —
but the object itself is immutable only once the bucket carries an object-lock
or retention policy, which belongs to the infrastructure that provisions the
bucket and not to this module. [AUTHZ.md](../AUTHZ.md) § "The daily archive, and
what \"sealed\" currently means" is the full statement.

## Configuration recipes

The SDK supplies `orgId`, `from`, and `to` through `runDashboard`'s viewer context.
Expand Down
Loading
Loading