M1-30: Expiry of grants and memberships¶
Status: ready | Depends on: M1-29 | Milestone: M1
Goal¶
After this ticket two periodic jobs in authz make expiry visible: authz.expiry_sweep reports every expired grant and group membership exactly once with a raised permission version, access.changed for each affected principal, the management events and an audit entry with the system actions grant.expire and group.member.expire; authz.expiry_reminder sends the personal event access.expiring exactly once before an expiry. The access decision itself never depends on either job: authorize already treats a row as invalid from the second of its expiry (07 section 2.5), and this ticket proves that again with the jobs switched off. Clients need the events to clear their caches and, from M2 on, runs of people who lost trigger end through the same in-transaction event.
Read first¶
docs/PLAN.md: section 3, section 7 (M1), rows M1-27 to M1-31.docs/spec/07-permissions.md: 2.5 (Validity of a grant), 4.4, 8.2, 8.3 (Expiry by time), 9.4 (Expiry reminder), 11.2 (AZ-P10), 12 (R-RE-09), 14 (configuration values).docs/spec/03-data-model.md: 4.3groups,group_memberships,grants(includingexpiry_reminded_atand the expiry index), 6.3, 6.4.docs/spec/04-sync-protocol.md: 2.2, 8.2.docs/spec/05-actions.md: rule 1.10 (system actions), table 4.4 (grant.expire,group.member.expireand the note below the table), 6 (access.changed,access.expiring,grant.changed,group.changed), 8 (R-AZ-15).docs/spec/02-architecture.md: 4.7, 8.1 to 8.3, 12.1 to 12.4 (jobs, queues, job catalogue, job rules).docs/api/openapi.yaml: schemasAccessExpiringPayload,AccessTarget,AccessTargetKind,GrantChangedPayload,GroupChangedPayload,Grant,Group,EventEnvelope.- ADRs: 0018, 0021, 0009, 0052, 0053.
docs/security/threat-model.md: T-AZ-03, T-AZ-04.- Merged code:
internal/platform/jobs(M1-14, periodic jobs and handler interface), the permission-change hook,VerifyInvariantsand the Admin port (M1-29), the grant store (M1-27), the snapshot builder (M1-28),auditing.Recorder(M1-24),kernel/eventlog.Appender(M1-16). Their names win where they differ from this brief only in naming. docs/quality/gates.md,CONTRIBUTING.md.
Fixed requirements that apply¶
- No MVP. New from scratch and directly in the final state; stages are only the order.
- Strictly modular: clear module and service boundaries, dependencies only through defined interfaces, no cross access.
- Documentation in code: every public module, class, function and interface with a docstring/doc comment (purpose, parameters, return value, errors, example where useful); comments explain the why, not the what. API documentation is generated from code (OpenAPI from the routes, dartdoc, Python API docs in MkDocs).
- Documentation in the repo: README per module/service, ADR for every architecture decision, CHANGELOG per release, keep the documentation site current. A change without matching documentation is not done.
- Quality gates (SonarQube, linters, type checking, security scanners, test coverage) block the merge.
- Test first: red before green, red output kept for the report.
- No en dash or em dash in any file; White Label.
- Configuration values only through named keys listed in
docs/operations/configuration.md; the one new key of this ticket is added there through its chapter table (Scope 6). - Expiry is checked at every
authorizecall; the jobs only report (07 principle 3, section 8.3, R-RE-09). Withauthz.expiry_sweepswitched off an expired grant still has no effect (AZ-P10). - The sweep writes, in one transaction per batch: raised permission version,
access.changed, audit entries with the system actionsgrant.expireandgroup.member.expire(07 section 8.3, R-AZ-15); invariants are verified as for every permission change (05 rule 1.6). - The reminder runs with the period
authz.expiry_sweep_interval, finds rows expiring withinauthz.expiry_reminder_leadand not yet reminded, and writesaccess.expiringexactly once (07 section 9.4); no e-mail (Baseline section 4). - Job arguments contain only IDs, never content or secrets (02 section 12.1); these jobs need no arguments.
- Lock order of 03 section 6.3.
Scope¶
0. Core contract¶
03 defines expiry_reported_at timestamptz NULL on grants and on group_memberships (set by authz.expiry_sweep when it has reported the expiry; allowed updates of grants are listed there) and the index group_memberships (expires_at) WHERE removed_at IS NULL AND expires_at IS NOT NULL. Without this durable marker the sweep could not report every expiry exactly once after the worker was down.
1. Migration¶
services/server/migrations/<next number>_authz_expiry.sql (goose, next free number, never edit an existing migration): M1-27 creates expiry_reminded_at, expiry_reported_at, both (expires_at) indexes and the immutability trigger that allows setting either column once (03); add here only what the merged M1-27 migration lacks, and adjust the trigger only if it does not yet allow expiry_reported_at. make migrations-lint stays green; regenerate sqlc (make codegen-check without drift).
2. Hook extension¶
In internal/authz/app/rightschange.go (M1-29) add AddAffectedAssumingValid(ctx, principals []ids.ID, assume AssumeValid) with AssumeValid{GrantIDs, MembershipIDs []ids.ID}: the before snapshot of these principals is computed with the listed rows treated as not expired. Extend the snapshot builder of M1-28 with this option inside internal/authz only; it never leaves the module. Reason: at sweep time the expired rows are already invalid for authorize, so a plain before snapshot would equal the after snapshot and the difference would be empty.
3. Job authz.expiry_sweep¶
Handler in internal/authz/app/expiry.go, registered through internal/platform/jobs in internal/wire for the roles worker and all, queue maintenance, period authz.expiry_sweep_interval. Algorithm per run, with t read once per batch from kernel/clock:
- Grants. Open a unit of work with
RightsChange{Action: grant.expire}(phase 1 lockspermission_version). Selectgrants WHERE revoked_at IS NULL AND expires_at IS NOT NULL AND expires_at <= t AND expiry_reported_at IS NULL ORDER BY id LIMIT authz.expiry_sweep_batch_max FOR UPDATE. If none: return a sentinel error so the transaction rolls back and the permission version does not move; treat the sentinel as success. Otherwise:- affected principals: the subject principal for subjects
user,agent,app; for a group subject all members withremoved_at IS NULLof a non-deleted group; register them withAddAffectedAssumingValidbefore any write; - set
expiry_reported_at = ton the selected rows; - append
grant.changedper row (enveloperesource = {kind: grant, id},scopewithspace_idand, for channel targets,channel_idresolved throughStructureSource; payloadGrantChangedPayload{grant: Grant, change: expired}); - record one audit entry per row: action
grant.expire,decision = 'allow', no actor (system),resource_kind = 'grant',resource_id= grant id,details = {subject_kind, subject_id, target_kind, target_id (absent for instance), expires_at}; - phase 3.1 (M1-29) verifies I1 and SR8, computes the differences and writes
access.changed. Repeat while a full batch was found.
- affected principals: the subject principal for subjects
- Memberships. Same with
RightsChange{Action: group.member.expire}andgroup_memberships WHERE removed_at IS NULL AND expires_at IS NOT NULL AND expires_at <= t AND expiry_reported_at IS NULL; affected principal is the member; eventgroup.changed(enveloperesource = {kind: group, id}, empty scope, payloadGroupChangedPayload{group: Group, change: member_expired, user_id}); audit actiongroup.member.expire,resource_kind = 'group',resource_id= group id,details = {membership_id, user_id, expires_at}. Memberships of deleted groups are marked and audited like others; their difference is empty, so noaccess.changedfollows. - An invariant violation inside the sweep (only possible in a world that was already invalid) aborts that batch, is logged with the action and the agent IDs, and the handler returns the error so that River retries; nothing is marked.
4. Job authz.expiry_reminder¶
Handler in the same file, same roles, queue maintenance, period authz.expiry_sweep_interval (07 section 9.4). Per batch, in one unit of work without permission change, t read once:
- Select
grants WHERE revoked_at IS NULL AND expires_at > t AND expires_at <= t + authz.expiry_reminder_lead AND expiry_reminded_at IS NULL ORDER BY id LIMIT authz.expiry_sweep_batch_max FOR UPDATE; skip grants of deleted groups. - Recipients per row (Ruling, see Design notes), deduplicated, only active principals: grants to
user,agentorapp: the subject andgranted_by; grants to a group: all active owners andgranted_by; memberships: the member andadded_by. - Append one
access.expiringper recipient:audience = self,resource = {kind: principal, id: <recipient>}, empty scope, no actor, payloadAccessExpiringPayload:kind(grantorgroup_membership),grant_idfor grants,group_idfor group grants and memberships,subject_id(subject principal, group id for a group grant, member for a membership),target(kind,id,level,capabilities) only for space and channel targets,expires_at. - Set
expiry_reminded_at = t. - Same for
group_memberships WHERE removed_at IS NULL AND expires_at > t AND expires_at <= t + lead AND expiry_reminded_at IS NULLof non-deleted groups. - Repeat while a full batch was found. Reminders write no audit entry: they change no permission and the catalogue has no action for them.
5. Specification (already decided)¶
openapi.yaml has member_expired in GroupChangedPayload.change and the note that AccessExpiringPayload.target is absent for instance grants; 07 sections 8.3, 9.4 and 14 state the sweep selection by expiry_reported_at, the batch key authz.expiry_sweep_batch_max and the recipient rule. Regenerate the server code if it predates these (make codegen-check without drift).
6. Configuration¶
Add authz.expiry_sweep_batch_max (integer, at least 1, default from 07 section 14) to the configuration schema of internal/platform/config and the typed authz configuration in internal/wire (M1-07 pattern), and regenerate docs/operations/configuration.md with the M1-08 tool so that make config-doc-check is green. authz.expiry_sweep_interval and authz.expiry_reminder_lead already exist; wire them if M1-28 or M1-29 did not.
7. Documentation¶
services/server/internal/authz/README.md (jobs table, algorithm, exactly-once argument), services/server/CHANGELOG.md under Unreleased. The rulings below are in 07 and openapi.yaml; docs/DECISIONS.md only for anything new.
Out of scope¶
- Cancelling runs of people who lost
trigger(run.cancel_requested):runssubscribes toAccessChangedin M2. This ticket only guarantees that the in-transaction event is published. - HTTP routes for grants and groups, the console list "Expiring soon": M1-48 and M4.
- Groups from OIDC claims and their expiry at login: M1-42 (its memberships expire through this sweep).
- Any change to how
authorizetreats expiry: done in M1-27 and M1-28.
Boundaries¶
- A new migration needs its line in
migrations.sha256: runmake migrations-lockafter creating the file and beforemake check; never edit a merged migration (M1-01, ADR 0007). - Work in a dedicated git worktree on the branch
feat/M1-30-authz-expiry, created from the base branch named by the launcher. One writer per worktree. - Conventional Commits with the module as scope (for example
feat(authz): report expired grants and memberships) and a DCO sign-off on every commit (git commit -s). Squash into meaningful commits before the merge. - Never push, tag, open pull requests or create remote repositories.
- Do not modify files outside the scope of this brief, except
docs/DECISIONS.md,services/server/CHANGELOG.mdand the generated files named here. - Follow the host rules supplied by the launcher. Tests bind only
127.0.0.1and port 0 (P-10); PostgreSQL throughpgtest(P-09), never skipped. - Stop only processes you started, by PID. Temporary directories only via
mktemp -d, removed at the end. - Never read or print secrets. No network calls except package downloads for pinned tools.
- No gate may be weakened, no threshold lowered, no test deleted or skipped.
Design notes¶
- Exactly once: marking (
expiry_reported_at,expiry_reminded_at), events and audit commit in one transaction. A crash or error rolls back everything, and the next run selects the same rows again. Rows expired while the worker was down are found on the next run because the selection is not bounded by the last run time. - No empty permission change: a sweep run that finds nothing rolls back its unit of work, so
permission_versiononly moves when something expired. Otherwise every run would invalidate every permission cache once perauthz.expiry_sweep_interval. - Lock order: phase 1 locks
permission_versionbefore the sweep selects rowsFOR UPDATEin ascendingid(03 section 6.3). The reminder takes nopermission_versionlock. - The difference uses
AssumeValid: before = snapshot attwith the batch rows treated as valid, after = snapshot att. This isolates the effect of exactly these expiries against the current state of all other rows. - A grant revoked or a membership removed before the sweep is not reported as expired; the revocation already produced its own
access.changed. - Recipients of
access.expiring(07 section 9.4): for a grant to a group all active owners andgranted_by, because a group is no principal that could receive a personal event; for memberships the member andadded_by. member_expiredinstead ofmember_removed, becausemember_removedwould claim a manual removal.access.expiringfor a grant on the instance omitstarget, becauseAccessTargetKindonly knowsspaceandchannel.- Events of the sweep carry
actor = {kind: system}withoutid(system actionsgrant.expire,group.member.expire; 04 section 2.2, 03event_log.actor_kind); events of the reminder carry noactor; audit entries haveactor_principal_idNULL (03audit_events). - Personal data: no names or e-mail addresses in events beyond the schemas, in audit details or in logs (SEC-LOG-4).
Tests (write them first)¶
Controllable clock, PostgreSQL 18 through pgtest, the world of 07 section 11.1 with real grants and memberships, the fake StructureSource and AgentPolicySource from M1-28 and M1-29. Call the handlers directly; River is not needed except for the registration test.
TestAZ_P10_ExpiryWithoutJob: no job registered or run;authorizeat exactlyexpires_atof G-3 (Robin) and of the Leser membership (Sam) answersnot_found, one microsecond earlierallow; the cached snapshot honorsvalid_until.TestExpirySweep_Grant: G-3 expires at T; the sweep at T plusauthz.expiry_sweep_intervalwrites exactly oneaccess.changedfor Robin withremovedcontaining the channel, onegrant.changedwithchange = expired, one audit rowgrant.expirewithout actor, setsexpiry_reported_at, and raisespermission_versionby one.TestExpirySweep_GroupGrant: a group grant expires; every member whose visible targets change getsaccess.changed; members with an independent grant on the same target get none.TestExpirySweep_Membership: the Leser membership expires; Sam getsaccess.changed;group.changedwithmember_expired; auditgroup.member.expire.TestExpirySweep_ExactlyOnce: two consecutive runs produce one set of events and audit rows; a run whose appender is replaced by a failing fake rolls back completely and the next run reports exactly once.TestExpirySweep_NoDueRowsKeepsVersion: nothing due,permission_versionunchanged, no events.TestExpirySweep_RevokedBeforeSweep: a revoked grant past its expiry is not reported.TestExpirySweep_Batches: withauthz.expiry_sweep_batch_maxset to two in the test config and five due rows, all are reported, in three transactions, andpermission_versionrises by three.TestExpirySweep_WorkerWasDown: rows that expired long before the run are reported on the next run.TestExpirySweep_InvariantStillVerified: with a world made invalid by a SQL fixture, the sweep batch is rejected, nothing is marked, and the handler returns an error.TestExpiryJobs_Registration: both jobs are registered on queuemaintenancewith the period fromauthz.expiry_sweep_interval, only for the rolesworkerandall, without arguments.TestExpiryReminder_*: a grant expiring at T gets nothing atT - lead - 1sand exactly oneaccess.expiringeach for subject and granter atT - lead + 1s; a second run adds nothing; a grant created with less than the lead left is reminded on the next run; a group grant reaches all active owners and the granter; a membership reaches member andadded_by; recipients are deduplicated; inactive recipients are skipped; instance grants omittarget; every payload validates againstAccessExpiringPayloadand contains no null.- Configuration: the schema knows
authz.expiry_sweep_batch_maxwith its default;make config-doc-checkis green.
Commands that must pass:
cd services/server && go test -race ./internal/authz/... ./internal/platform/config/...make migrations-lint,make codegen-check,make api-lint,make config-doc-check,make docsmake check
Acceptance criteria¶
- Precondition checked; the migration adds only what is missing.
- AZ-P10 green with no job running.
-
access.changedafter an expiry is written by the first sweep run after the expiry, that is withinauthz.expiry_sweep_interval. - Every expiry is reported exactly once (tests 5 and 9), with audit
grant.expireorgroup.member.expire. - Every reminder is sent exactly once,
authz.expiry_reminder_leadbefore the expiry. - A run without due rows leaves
permission_versionunchanged. -
GroupChangedPayloadknowsmember_expired; generated code without drift. -
authz.expiry_sweep_batch_maxin 07 section 14, in the schema and in the generated reference. - README, DECISIONS and CHANGELOG updated; doc comments on every exported symbol.
-
make checkgreen.
Definition of done¶
- Code, tests and documentation as listed in Scope; the module README is current (jobs, events, tables).
- A doc comment on every exported symbol (purpose, parameters, return value, errors, example where useful); comments explain why.
- An ADR for every architecture decision taken here (none expected; rulings go to
docs/DECISIONS.md). - CHANGELOG entry under Unreleased in
services/server/CHANGELOG.md. make checkgreen on the final commit.- Independent review of the diff (
make reviewplus a code review by a fresh reviewer) and the red team ofdocs/quality/red-team-gate.mdover the diff, verdict recorded in the report. OnNEIN: fix, add a test per finding, re-check the delta. - Merge locally into the base branch only when the review is clean, the red team says
JAandmake checkis green; thengit worktree remove.
Report¶
Write {{REPORT_PATH}}:
- first line exactly
Verdict: DONEorVerdict: BLOCKED <reason>; - commits (sha and subject), files changed with line counts, test results with counts (zero skipped), the red output of the first run of each new test file, what is not proven, follow-ups that could not be done in scope with the reason.
Then write {{DONE_PATH}} with exit=0 sha=<merge sha> or exit=1 <reason>.