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
Showing only changes of commit 35826ab695 - Show all commits

feat(acl): live-policy auth in checkIfPubkeyAllowed (#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
Padreug 2026-05-30 13:25:48 +02:00

View file

@ -1,13 +1,32 @@
import { NDKEvent, NostrEvent } from '@nostr-dev-kit/ndk'; import { NDKEvent, NostrEvent } from '@nostr-dev-kit/ndk';
import prisma from '../../../db.js'; import prisma from '../../../db.js';
/**
* Layered authorization check. Order matters:
*
* 1. fetch KeyUser; if missing undefined (no binding exists)
* 2. if 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 (denied)
*
* Step 3 must precede step 4: per-user denies override the policy, and
* per-user grants extend beyond the policy. Step 2 must precede step 3:
* a revoked KeyUser stays revoked regardless of conditions or policy.
*
* See aiolabs/nsecbunkerd#11 and the issue comment that ratified the
* algorithm (https://git.atitlan.io/aiolabs/nsecbunkerd/issues/11#issuecomment-1473).
*/
export async function checkIfPubkeyAllowed( export async function checkIfPubkeyAllowed(
keyName: string, keyName: string,
remotePubkey: string, remotePubkey: string,
method: IMethod, method: IMethod,
payload?: string | NostrEvent payload?: string | NostrEvent
): Promise<boolean | undefined> { ): Promise<boolean | undefined> {
// find KeyUser // Step 1: find KeyUser.
const keyUser = await prisma.keyUser.findUnique({ const keyUser = await prisma.keyUser.findUnique({
where: { unique_key_user: { keyName, userPubkey: remotePubkey } }, where: { unique_key_user: { keyName, userPubkey: remotePubkey } },
}); });
@ -16,9 +35,12 @@ export async function checkIfPubkeyAllowed(
return undefined; return undefined;
} }
// find SigningCondition // Step 2: binary user revoke.
const signingConditionQuery = requestToSigningConditionQuery(method, payload); if (keyUser.revokedAt) {
return false;
}
// Step 3a: explicit-reject override (rejectAllRequestsFromKey writes this).
const explicitReject = await prisma.signingCondition.findFirst({ const explicitReject = await prisma.signingCondition.findFirst({
where: { where: {
keyUserId: keyUser.id, keyUserId: keyUser.id,
@ -32,6 +54,9 @@ export async function checkIfPubkeyAllowed(
return false; return false;
} }
// Step 3b: matching per-(method, kind) override.
const signingConditionQuery = requestToSigningConditionQuery(method, payload);
const signingCondition = await prisma.signingCondition.findFirst({ const signingCondition = await prisma.signingCondition.findFirst({
where: { where: {
keyUserId: keyUser.id, keyUserId: keyUser.id,
@ -39,32 +64,53 @@ export async function checkIfPubkeyAllowed(
} }
}); });
// if no SigningCondition found, return undefined if (signingCondition && (signingCondition.allowed === true || signingCondition.allowed === false)) {
if (!signingCondition) { console.log(`found signing condition`, signingCondition);
return undefined; return signingCondition.allowed;
} }
const allowed = signingCondition.allowed; // Step 4: live policy join. Walk every non-revoked Token bound to this
// KeyUser; if any of their policies has a matching PolicyRule, allow.
//
// PolicyRule.kind matching:
// - exact match against payload kind (stringified — matches the
// create_new_policy.ts:23 storage format `rule.kind.toString()`)
// - 'all' literal matches any kind (parity with the override-layer
// allowScopeToSigningConditionQuery convention)
// - NULL kind is a defensive branch — no current code path inserts
// PolicyRules with null kind, but if one ever appears (raw SQL,
// future code, schema migration) we treat it as a wildcard rather
// than failing closed silently.
const payloadKindString = (method === 'sign_event' && typeof payload === 'object' && payload?.kind !== undefined)
? payload.kind.toString()
: undefined;
// Check if the key user has been revoked const kindMatchers: Array<{ kind: string | null }> = [{ kind: null }, { kind: 'all' }];
if (allowed) { if (payloadKindString !== undefined) {
const revoked = await prisma.keyUser.findFirst({ kindMatchers.push({ kind: payloadKindString });
}
const policyAllowance = await prisma.token.findFirst({
where: { where: {
id: keyUser.id, keyUserId: keyUser.id,
revokedAt: { not: null }, revokedAt: null,
} policy: {
rules: {
some: {
method,
OR: kindMatchers,
},
},
},
},
}); });
if (revoked) { if (policyAllowance) {
return false; return true;
}
}
if (allowed === true || allowed === false) {
console.log(`found signing condition`, signingCondition);
return allowed;
} }
// Step 5: no override granted, no policy rule matched. Caller's
// requestPermission flow may still prompt the admin out-of-band.
return undefined; return undefined;
} }