feat(#11): live-policy auth + 6 companion admin RPCs + Token.revokedAt #13
1 changed files with 69 additions and 23 deletions
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
commit
35826ab695
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue