feat(#11): live-policy auth + 6 companion admin RPCs + Token.revokedAt #13

Merged
padreug merged 3 commits from issue-11-live-policy-auth into dev 2026-05-30 15:25:16 +00:00
Owner

Summary

Closes #11. Three commits implementing the design ratified by webapp in issue #11 comment 1473:

  • chore(schema): add Token.revokedAt for surgical token revocation
  • feat(acl): live-policy auth in checkIfPubkeyAllowed
  • feat(admin): companion RPCs for live policy + token revocation

What changes

Sign-time authorization shifts from materialized SigningCondition snapshots (frozen at token-bind time, can't be modified once issued) to a layered live lookup:

  1. fetch KeyUser; missing → undefined
  2. KeyUser.revokedAt set → false (binary user revoke beats everything)
  3. SigningCondition override layer (per-user grants/denies):
    • explicit reject (method='*', allowed=false) → false
    • matching per-(method, kind) row → return row.allowed
  4. live policy join over KeyUser → Token → Policy → PolicyRule with Token.revokedAt IS NULL and a matching rule → true
  5. else → undefined (caller may still prompt admin)

Six new admin RPCs operate on the policy/condition graph, each following the canonical create_new_policy.ts pattern (single JSON-stringified param, kind-24134 response, one prisma mutation, gated by existing admin-npub allowlist):

RPC Param Effect
add_policy_rule { policyId, rule: {method, kind?, maxUsageCount?} } INSERT PolicyRule — propagates live to every bound KeyUser
remove_policy_rule { ruleId } DELETE PolicyRule — revokes live for every bound KeyUser
update_policy { policyId, patch: {name?, expiresAt?} } UPDATE Policy fields
add_signing_condition { keyUserId, condition: {method, kind?, allowed} } INSERT per-user override (grant or deny)
remove_signing_condition { conditionId } DELETE override
revoke_token { tokenId } UPDATE Token.revokedAt = now() — surgical revoke without nuking the KeyUser

The 'all' kind literal is honored as a wildcard in add_policy_rule for parity with the existing allowScopeToSigningConditionQuery convention.

Backwards compatibility

applyToken's SigningCondition fan-out is left intact. Existing bound users keep working because step 3 (override layer) finds their snapshot rows. The downstream cleanup — trimming the fan-out once step 4 is the established happy path — is tracked separately as #12.

Dogfood — 8/8 PASS

Webapp ran the 9-case regression from comment 1473 §3 against this branch on bohm regtest (see coord log 2026-05-30T12:05Z). All 8 functional cases pass (case 9 — maxUsageCount — is out of scope per the original spec):

# Case Result
1, 2 Backwards-compat (snapshotted SigningCondition still authorizes via step 3)
3 Per-user deny override (add_signing_condition allowed=false) denies even when policy allows
4 Per-user allow grant (add_signing_condition allowed=true) authorizes kind the policy doesn't
5 add_policy_rule immediately enables sign_event for previously-bound users (headline feature)
6 remove_policy_rule immediately revokes — live denial via step 4
7 revoke_user continues to deny everything regardless of conditions/policy (step 2)
8 revoke_token denies under that token, leaves other tokens for same KeyUser working

The container entrypoint runs prisma migrate deploy cleanly on startup, applies the 20260530112308_add_token_revoked_at migration, and serves kind-24134 admin RPCs + kind-24133 NIP-46 sessions normally.

Caveats worth flagging for reviewers

  1. undefined is a silent NIP-46 drop, not an error event. Only explicit false (step 2 or 3 deny) sends back ["error", "Not authorized"]. undefined (step 5 "no rule matched") produces no response at all; clients see a timeout. This is how NDKNip46Backend interprets a falsy permit result — not a bug, but a thing test harnesses must be aware of.
  2. One transient invalid Schnorr signature during dogfood on a freshly-provisioned account's first kind-31923 publish. Did not reproduce across 3 immediate retries. Bunker authorized and signed cleanly; lnbits-side strict verify rejected the returned event. Same class as the NDK first-call hiccup observed elsewhere. Not auth-related, not blocking.
  3. applyToken fan-out interaction with new RPCs. Step 3 (override) short-circuits before step 4 (live policy), so any test exercising step 4 must avoid step-3 contamination. The dogfood harness uses an empty-rules policy + post-bind add_policy_rule to keep the live-policy path clean. This redundancy is what #12 will trim once step 4 is the established happy path. Cross-linked above.

Test plan

  • Webapp dogfood — 8/8 functional cases pass on regtest (see coord log 2026-05-30T12:05Z)
  • Reviewer can replay via NSECBUNKER_SRC=/home/padreug/dev/nsecbunkerd/dev docker compose -f docker-compose.dev.yml up -d --build nsecbunker from ~/dev/local/docker/regtest, then run the Python harness at /tmp/smoke11_cases.py (recoverable from coord log §"Harness shape")
  • Schema migration applies cleanly on startup; Token.revokedAt present
  • Backwards-compat: pre-existing SigningCondition rows still grant via step 3 (cases 1+2)

cc #11 (the ratified design), #12 (the pre-filed follow-up).

🤖 Generated with Claude Code

## Summary Closes #11. Three commits implementing the design ratified by webapp in [issue #11 comment 1473](https://git.atitlan.io/aiolabs/nsecbunkerd/issues/11#issuecomment-1473): - `chore(schema): add Token.revokedAt for surgical token revocation` - `feat(acl): live-policy auth in checkIfPubkeyAllowed` - `feat(admin): companion RPCs for live policy + token revocation` ### What changes Sign-time authorization shifts from **materialized `SigningCondition` snapshots** (frozen at token-bind time, can't be modified once issued) to a **layered live lookup**: 1. fetch `KeyUser`; missing → `undefined` 2. `KeyUser.revokedAt` set → `false` (binary user revoke beats everything) 3. `SigningCondition` override layer (per-user grants/denies): - explicit reject (`method='*'`, `allowed=false`) → `false` - matching per-`(method, kind)` row → return `row.allowed` 4. live policy join over `KeyUser → Token → Policy → PolicyRule` with `Token.revokedAt IS NULL` and a matching rule → `true` 5. else → `undefined` (caller may still prompt admin) Six new admin RPCs operate on the policy/condition graph, each following the canonical `create_new_policy.ts` pattern (single JSON-stringified param, `kind-24134` response, one prisma mutation, gated by existing admin-npub allowlist): | RPC | Param | Effect | |---|---|---| | `add_policy_rule` | `{ policyId, rule: {method, kind?, maxUsageCount?} }` | INSERT PolicyRule — propagates live to every bound KeyUser | | `remove_policy_rule` | `{ ruleId }` | DELETE PolicyRule — revokes live for every bound KeyUser | | `update_policy` | `{ policyId, patch: {name?, expiresAt?} }` | UPDATE Policy fields | | `add_signing_condition` | `{ keyUserId, condition: {method, kind?, allowed} }` | INSERT per-user override (grant or deny) | | `remove_signing_condition` | `{ conditionId }` | DELETE override | | `revoke_token` | `{ tokenId }` | UPDATE `Token.revokedAt = now()` — surgical revoke without nuking the KeyUser | The `'all'` kind literal is honored as a wildcard in `add_policy_rule` for parity with the existing `allowScopeToSigningConditionQuery` convention. ### Backwards compatibility `applyToken`'s SigningCondition fan-out is **left intact**. Existing bound users keep working because step 3 (override layer) finds their snapshot rows. The downstream cleanup — trimming the fan-out once step 4 is the established happy path — is tracked separately as #12. ### Dogfood — 8/8 PASS Webapp ran the 9-case regression from comment 1473 §3 against this branch on bohm regtest (see [coord log 2026-05-30T12:05Z](https://git.atitlan.io/aiolabs/nsecbunkerd/issues/11)). All 8 functional cases pass (case 9 — `maxUsageCount` — is out of scope per the original spec): | # | Case | Result | |---|---|---| | 1, 2 | Backwards-compat (snapshotted SigningCondition still authorizes via step 3) | ✅ | | 3 | Per-user deny override (`add_signing_condition allowed=false`) denies even when policy allows | ✅ | | 4 | Per-user allow grant (`add_signing_condition allowed=true`) authorizes kind the policy doesn't | ✅ | | 5 | `add_policy_rule` immediately enables sign_event for previously-bound users (**headline feature**) | ✅ | | 6 | `remove_policy_rule` immediately revokes — live denial via step 4 | ✅ | | 7 | `revoke_user` continues to deny everything regardless of conditions/policy (step 2) | ✅ | | 8 | `revoke_token` denies under that token, leaves other tokens for same KeyUser working | ✅ | The container entrypoint runs `prisma migrate deploy` cleanly on startup, applies the `20260530112308_add_token_revoked_at` migration, and serves kind-24134 admin RPCs + kind-24133 NIP-46 sessions normally. ### Caveats worth flagging for reviewers 1. **`undefined` is a silent NIP-46 drop, not an error event.** Only explicit `false` (step 2 or 3 deny) sends back `["error", "Not authorized"]`. `undefined` (step 5 "no rule matched") produces no response at all; clients see a timeout. This is how `NDKNip46Backend` interprets a falsy permit result — not a bug, but a thing test harnesses must be aware of. 2. **One transient `invalid Schnorr signature` during dogfood** on a freshly-provisioned account's first kind-31923 publish. Did not reproduce across 3 immediate retries. Bunker authorized and signed cleanly; lnbits-side strict verify rejected the returned event. Same class as the NDK first-call hiccup observed elsewhere. **Not auth-related, not blocking.** 3. **`applyToken` fan-out interaction with new RPCs.** Step 3 (override) short-circuits before step 4 (live policy), so any test exercising step 4 must avoid step-3 contamination. The dogfood harness uses an empty-rules policy + post-bind `add_policy_rule` to keep the live-policy path clean. This redundancy is what #12 will trim once step 4 is the established happy path. Cross-linked above. ### Test plan - [x] Webapp dogfood — 8/8 functional cases pass on regtest (see coord log 2026-05-30T12:05Z) - [ ] Reviewer can replay via `NSECBUNKER_SRC=/home/padreug/dev/nsecbunkerd/dev docker compose -f docker-compose.dev.yml up -d --build nsecbunker` from `~/dev/local/docker/regtest`, then run the Python harness at `/tmp/smoke11_cases.py` (recoverable from coord log §"Harness shape") - [x] Schema migration applies cleanly on startup; `Token.revokedAt` present - [x] Backwards-compat: pre-existing SigningCondition rows still grant via step 3 (cases 1+2) cc #11 (the ratified design), #12 (the pre-filed follow-up). 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Pre-requisite for the live-policy auth rewrite in #11. The new
revoke_token admin RPC needs a way to mark a single Token as
revoked without nuking the whole KeyUser (revoke_user) or
conflating with future expiry cleanup (deletedAt).

Nullable DateTime — existing rows default to NULL (active), no
data migration needed.

refs: #11
Shifts sign-time authorization from materialized SigningCondition
snapshots (frozen at token-bind time) to a layered model:

  1. fetch KeyUser; missing → undefined
  2. KeyUser.revokedAt set → false
  3. SigningCondition override layer (explicit reject, per-(method,
     kind) grant/deny) → return matching row's allowed value
  4. live policy join: KeyUser → Token (revokedAt IS NULL) →
     Policy → PolicyRule. Match on method + kind (exact /
     'all' / NULL defensive) → true
  5. else → undefined (caller may still prompt admin)

Backwards-compatible. The existing applyToken fan-out of
SigningCondition rows continues to populate step 3 for legacy
auth, so already-bound users keep working with no behavior change.
New users will still go through that fan-out until the follow-up
trim (#12). The key win: PolicyRule mutations via the upcoming
companion RPCs (add_policy_rule / remove_policy_rule / etc.)
propagate live to every KeyUser bound to that policy, rather than
requiring per-user backfill RPCs that don't exist.

Algorithm ratified by webapp in
#11 (comment).

refs: #11
feat(admin): companion RPCs for live policy + token revocation (#11)
Some checks failed
Docker image / build-and-push-image (push) Has been cancelled
49091f722f
Six new admin-RPC handlers that complement the live-policy auth
rewrite. Each follows the canonical create_new_policy.ts pattern
(single JSON-stringified param, kind-24134 response, one prisma
mutation, no per-call ACL — validateRequestFromAdmin gates them
via the admin-npub allowlist).

Policy-level mutations propagate to every KeyUser bound to the
policy at the next sign-time check (the point of #11):

  add_policy_rule    { policyId, rule: {method, kind?, maxUsageCount?} }
  remove_policy_rule { ruleId }
  update_policy      { policyId, patch: {name?, expiresAt?} }

Per-KeyUser override mutations (override layer):

  add_signing_condition    { keyUserId, condition: {method, kind?, allowed} }
  remove_signing_condition { conditionId }

Surgical token revocation without nuking the KeyUser:

  revoke_token { tokenId }

The 'all' kind literal is honored as a wildcard in add_policy_rule
for parity with the SigningCondition override-layer convention.

Param shapes ratified by webapp in
#11 (comment).

refs: #11
padreug deleted branch issue-11-live-policy-auth 2026-05-30 15:25:16 +00:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
aiolabs/nsecbunkerd!13
No description provided.