fix(calendar): bind subscription use to issuance membership epoch - #539
fix(calendar): bind subscription use to issuance membership epoch#539cursor[bot] wants to merge 26 commits into
Conversation
6f2cbf3 to
59c1008
Compare
Authorize calendar-read use against the stored membership epoch so remove-then-rejoin cannot revive an unrevoked feed secret. Freeze purpose calendar_read on the principal, cap lifetime at 366 days, reject exact expiry on the use path, and audit revocation only on the first transition. Co-authored-by: Seongho Bae <seonghobae@users.noreply.github.com>
Rebase onto current #506 dropped the return-boundary entries from package.json and the coverage-script contract. Restore them so the isolated atomic-return regression stays in test:unit and c8. Co-authored-by: Seongho Bae <seonghobae@users.noreply.github.com>
8d56da4 to
036ff73
Compare
| const used = await repository.recordUsageAtomically(secretHash, { | ||
| now_ms: nowMs, | ||
| project_id: projectId, | ||
| purpose: CALENDAR_SUBSCRIPTION_PURPOSE, | ||
| audience: CALENDAR_SUBSCRIPTION_AUDIENCE, | ||
| membership_version: issuedMembershipVersion, | ||
| }); | ||
| if ( | ||
| !used | ||
| || used.subscription_id !== existing.subscription_id | ||
| || used.subject_id !== existing.subject_id | ||
| || used.project_id !== existing.project_id | ||
| || used.purpose !== CALENDAR_SUBSCRIPTION_PURPOSE | ||
| || used.audience !== CALENDAR_SUBSCRIPTION_AUDIENCE | ||
| || used.membership_version !== issuedMembershipVersion | ||
| || statusOf(used, nowMs) !== 'active' | ||
| ) { | ||
| throw unauthorizedSubscription(); | ||
| } |
There was a problem hiding this comment.
📝 Info: Epoch protection depends on adapter transaction
Remove-then-rejoin protection requires recordUsageAtomically to compare both the stored row epoch and live membership to the supplied issued epoch in one transaction. The domain passes and re-checks the epoch but cannot enforce the transactional live-vs-stored comparison itself; that invariant lives in the unshipped adapter under issue #413.
Was this helpful? React with 👍 or 👎 to provide feedback.
| if ( | ||
| !existing | ||
| || existing.project_id !== projectId | ||
| || existing.audience !== CALENDAR_SUBSCRIPTION_AUDIENCE | ||
| || existing.purpose !== CALENDAR_SUBSCRIPTION_PURPOSE | ||
| ) { | ||
| throw unauthorizedSubscription(); | ||
| } | ||
| const issuedMembershipVersion = normalizeMembershipVersion(existing.membership_version); | ||
| const liveMembershipVersion = await readMembershipVersion( | ||
| membershipRevocation, | ||
| existing.subject_id, | ||
| existing.project_id, | ||
| ); | ||
| if (liveMembershipVersion !== issuedMembershipVersion) { | ||
| throw unauthorizedSubscription(); |
There was a problem hiding this comment.
📝 Info: authorize and list disagree on omitted-purpose rows
authorize requires existing.purpose to equal calendar_read exactly, rejecting rows with an omitted purpose. validateListedSubscription normalizes the same omitted purpose to calendar_read, so such a row lists as active but can never authorize a feed. Both directions fail closed and no adapter writes such rows yet; relevant when the #413 persistence adapter lands.
Was this helpful? React with 👍 or 👎 to provide feedback.
Buyer/security outcome
Refs #413. This is the framework-neutral calendar-subscription domain stacked on the access-grant prerequisite #506. Reusable calendar URLs are purpose-bound to
calendar_read, audience-bound to ScopeWeave calendar reads, tied to the issuance membership epoch, capped to a 366-day lifetime, and fail closed at repository return boundaries before lifecycle metadata, principals, or revocation audit evidence can cross the domain boundary.No protected Hono route, production SQLite bootstrap/migration, or customer UI is claimed by this PR.
Exact current stack
feat/access-grant-domain-413@f3ae1f1f4ee66024776507d75f8fb752b2bf748c.e2f560078d3e2862c5dc6c395c159d12dfe1e6fa.0800d793e27589df8aa3040442d8960db07c6b98; because this parent moved after the revocation-boundary repair, fix(calendar): bind omitted purpose to calendar_read #541 is currently behind the live parent and must be reconciled non-destructively before its prior stack/CI evidence can be trusted again.Security and correctness contract
authorize()uses the stored issuance membership version and requires the live version to match it before attempting atomic use.recordUsageAtomically()output is untrusted repository data: subscription, subject, project, purpose, audience, membership epoch, and active state must still match before a principal or audit event is emitted.rotate()receives the current live epoch so an authorized operator can re-bind after rejoin while invalidating the previous secret.rotateSubscriptionAtomically()output must match subscription, subject, project,calendar_readpurpose, calendar audience, current membership epoch, requested expiry, and active state.listSubscriptions()output is untrusted.list()rejects malformed values and any row belonging to a different subject/project, explicit non-calendar purpose, or non-calendar audience before metadata is exposed. Omitted legacy purpose remains normalized tocalendar_read.revokeSubscriptionAtomically()output is also untrusted.revoke()now rejects malformed values, wrong subscription/subject/project bindings, explicit non-calendar purpose, wrong audience, non-booleanrevocation_applied, and any row that is not actually revoked before audit or lifecycle metadata can leave the domain. Omitted legacy purpose remains normalized tocalendar_read.revoked_at_msand audits only when the validated adapter result reportsrevocation_applied: true.TDD / repair evidence
The earlier list-return repair first failed RED at test-only head
857fa5b18db77cbb0031d6178aaec2df13ee6d87withMissing expected rejection, then was fixed while preserving omitted-purpose compatibility.The current review finding identified a separate asymmetry:
revoke()trusted the repository-returned row. A realistic regression was added at test-only head6875f5a7882e16223071c145091999c89fe42f29; hosted Server Tests run33087613603failed RED intests/unit/calendar-subscription-return-boundary.test.mjswithAssertionError: Missing expected rejection.The root fix is exact head
e2f560078d3e2862c5dc6c395c159d12dfe1e6fa. It validates the atomic revoke return before audit orviewOf()and explicitly tests malformed/foreign rows, active rows, non-boolean transition markers, and zero-audit behavior for rejected results. The addressed Devin thread is resolved on this head.Current-head-associated evidence is terminal success:
33088017724— success; bothunit-and-apiandcloud-e2epassed, and the log includescalendar subscription atomic return boundary tests passed;33088017651— success; and33088018189— success.Server Tests still checked out GitHub synthetic merge
82914ebf08e4f32b5b5cdadfc6ce4ccba9dd916f(e2f56007...merged into parentf3ae1f1f...), so this is behavioral evidence rather than exact-contributor-head merge authority under the repository's current evidence contract. No qualifying independent current-head/last-push approval exists.Merge gate
Remain Draft and do not integrate independently of #506. After the prerequisite reaches protected
develop, reconcile this bounded semantic diff onto the resulting live head and require every then-applicable exact-head CI, browser E2E, owned statement/branch/function/line coverage and docstrings, SAST/security/dependency/supply-chain/package/provenance/recovery gate, zero valid unresolved findings, and qualifying independent current-head approval under live policy. Pending, queued, skipped-required, cancelled, absent, neutral, failed, stale, predecessor, synthetic-only, status-only, author-only, or model-only evidence is non-passing.Docs
docs/doctoring/calendar-subscription-domain.mdrecords the issuance-epoch contract,calendar_readpurpose, 366-day cap, and APA 7 references including RFC 5545, RFC 6750, RFC 9700/BCP 240, and RFC 8725.ARCHITECTURE.mdandCHANGELOG.mdremain part of this slice.