97 lines
3.2 KiB
TypeScript
97 lines
3.2 KiB
TypeScript
import type { PrincipalResolution } from '@/lib/server/authz/principal';
|
|
|
|
export type ResourceAuthorizationAction = 'read' | 'write' | 'cancel' | 'resume' | 'delete';
|
|
|
|
export type ResourceOwnership =
|
|
| { readonly state: 'owned'; readonly ownerPrincipalId: string }
|
|
| { readonly state: 'guest-owned'; readonly guestPrincipalId: string }
|
|
| { readonly state: 'public-read' }
|
|
| { readonly state: 'legacy-unowned' }
|
|
| { readonly state: 'unknown' };
|
|
|
|
export type ResourceAuthorizationDecision =
|
|
| {
|
|
readonly effect: 'allow';
|
|
readonly reason: 'owner_match' | 'guest_match' | 'public_read' | 'explicit_capability';
|
|
}
|
|
| {
|
|
readonly effect: 'deny';
|
|
readonly reason: 'unauthenticated' | 'owner_mismatch' | 'action_not_allowed';
|
|
}
|
|
| {
|
|
readonly effect: 'indeterminate';
|
|
readonly reason: 'resolver_unavailable' | 'owner_missing' | 'owner_unknown';
|
|
};
|
|
|
|
export interface ResourceAuthorizationInput {
|
|
readonly principal: PrincipalResolution;
|
|
readonly ownership: ResourceOwnership;
|
|
readonly action: ResourceAuthorizationAction;
|
|
/**
|
|
* An exact capability which may override an owner mismatch. Principal kind
|
|
* alone never grants elevated access.
|
|
*/
|
|
readonly overrideCapability?: string;
|
|
}
|
|
|
|
/** Pure ownership policy. It performs no authentication and no I/O. */
|
|
export function decideResourceAuthorization(
|
|
input: ResourceAuthorizationInput,
|
|
): ResourceAuthorizationDecision {
|
|
const { ownership, principal, action } = input;
|
|
|
|
if (ownership.state === 'legacy-unowned') {
|
|
return { effect: 'indeterminate', reason: 'owner_missing' };
|
|
}
|
|
if (
|
|
ownership.state === 'unknown' ||
|
|
(ownership.state === 'owned' && ownership.ownerPrincipalId.trim().length === 0) ||
|
|
(ownership.state === 'guest-owned' && ownership.guestPrincipalId.trim().length === 0)
|
|
) {
|
|
return { effect: 'indeterminate', reason: 'owner_unknown' };
|
|
}
|
|
|
|
if (ownership.state === 'public-read') {
|
|
return action === 'read'
|
|
? { effect: 'allow', reason: 'public_read' }
|
|
: { effect: 'deny', reason: 'action_not_allowed' };
|
|
}
|
|
|
|
if (principal.state === 'unavailable') {
|
|
return { effect: 'indeterminate', reason: 'resolver_unavailable' };
|
|
}
|
|
if (principal.state === 'anonymous') {
|
|
return { effect: 'deny', reason: 'unauthenticated' };
|
|
}
|
|
|
|
if (
|
|
ownership.state === 'owned' &&
|
|
(principal.principal.principalId === ownership.ownerPrincipalId ||
|
|
principal.principal.userId === ownership.ownerPrincipalId)
|
|
) {
|
|
return { effect: 'allow', reason: 'owner_match' };
|
|
}
|
|
|
|
if (
|
|
ownership.state === 'guest-owned' &&
|
|
principal.principal.kind === 'device' &&
|
|
principal.principal.principalId === ownership.guestPrincipalId
|
|
) {
|
|
return { effect: 'allow', reason: 'guest_match' };
|
|
}
|
|
|
|
if (
|
|
input.overrideCapability &&
|
|
principal.principal.capabilities.includes(input.overrideCapability)
|
|
) {
|
|
return { effect: 'allow', reason: 'explicit_capability' };
|
|
}
|
|
|
|
return { effect: 'deny', reason: 'owner_mismatch' };
|
|
}
|
|
|
|
/** Future enforcement must fail closed for deny and indeterminate decisions. */
|
|
export function isResourceAuthorizationAllowed(decision: ResourceAuthorizationDecision): boolean {
|
|
return decision.effect === 'allow';
|
|
}
|