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
This commit is contained in:
parent
eb6c86a4d1
commit
35826ab695
1 changed files with 69 additions and 23 deletions
|
|
@ -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<boolean | undefined> {
|
||||
// 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;
|
||||
if (signingCondition && (signingCondition.allowed === true || signingCondition.allowed === false)) {
|
||||
console.log(`found signing condition`, signingCondition);
|
||||
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
|
||||
if (allowed) {
|
||||
const revoked = await prisma.keyUser.findFirst({
|
||||
const kindMatchers: Array<{ kind: string | null }> = [{ kind: null }, { kind: 'all' }];
|
||||
if (payloadKindString !== undefined) {
|
||||
kindMatchers.push({ kind: payloadKindString });
|
||||
}
|
||||
|
||||
const policyAllowance = await prisma.token.findFirst({
|
||||
where: {
|
||||
id: keyUser.id,
|
||||
revokedAt: { not: null },
|
||||
}
|
||||
keyUserId: keyUser.id,
|
||||
revokedAt: null,
|
||||
policy: {
|
||||
rules: {
|
||||
some: {
|
||||
method,
|
||||
OR: kindMatchers,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (revoked) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (allowed === true || allowed === false) {
|
||||
console.log(`found signing condition`, signingCondition);
|
||||
return allowed;
|
||||
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;
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue