diff --git a/src/daemon/lib/acl/index.ts b/src/daemon/lib/acl/index.ts index 42af57d..b32f954 100644 --- a/src/daemon/lib/acl/index.ts +++ b/src/daemon/lib/acl/index.ts @@ -1,13 +1,32 @@ import { NDKEvent, NostrEvent } from '@nostr-dev-kit/ndk'; 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( keyName: string, remotePubkey: string, method: IMethod, payload?: string | NostrEvent ): Promise { - // find KeyUser + // Step 1: find KeyUser. const keyUser = await prisma.keyUser.findUnique({ where: { unique_key_user: { keyName, userPubkey: remotePubkey } }, }); @@ -16,9 +35,12 @@ export async function checkIfPubkeyAllowed( return undefined; } - // find SigningCondition - const signingConditionQuery = requestToSigningConditionQuery(method, payload); + // Step 2: binary user revoke. + if (keyUser.revokedAt) { + return false; + } + // Step 3a: explicit-reject override (rejectAllRequestsFromKey writes this). const explicitReject = await prisma.signingCondition.findFirst({ where: { keyUserId: keyUser.id, @@ -32,6 +54,9 @@ export async function checkIfPubkeyAllowed( return false; } + // Step 3b: matching per-(method, kind) override. + const signingConditionQuery = requestToSigningConditionQuery(method, payload); + const signingCondition = await prisma.signingCondition.findFirst({ where: { keyUserId: keyUser.id, @@ -39,32 +64,53 @@ export async function checkIfPubkeyAllowed( } }); - // if no SigningCondition found, return undefined - if (!signingCondition) { - return undefined; - } - - const allowed = signingCondition.allowed; - - // Check if the key user has been revoked - if (allowed) { - const revoked = await prisma.keyUser.findFirst({ - where: { - id: keyUser.id, - revokedAt: { not: null }, - } - }); - - if (revoked) { - return false; - } - } - - if (allowed === true || allowed === false) { + if (signingCondition && (signingCondition.allowed === true || signingCondition.allowed === false)) { console.log(`found signing condition`, signingCondition); - return allowed; + return 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; + + const kindMatchers: Array<{ kind: string | null }> = [{ kind: null }, { kind: 'all' }]; + if (payloadKindString !== undefined) { + kindMatchers.push({ kind: payloadKindString }); + } + + const policyAllowance = await prisma.token.findFirst({ + where: { + keyUserId: keyUser.id, + revokedAt: null, + policy: { + rules: { + some: { + method, + OR: kindMatchers, + }, + }, + }, + }, + }); + + if (policyAllowance) { + return true; + } + + // Step 5: no override granted, no policy rule matched. Caller's + // requestPermission flow may still prompt the admin out-of-band. return undefined; }