178 lines
5.7 KiB
TypeScript
178 lines
5.7 KiB
TypeScript
import { describe, expect, test } from 'vitest';
|
|
import { NextRequest } from 'next/server';
|
|
import {
|
|
resolveRequestPrincipal,
|
|
type PrincipalKind,
|
|
type PrincipalResolution,
|
|
} from '@/lib/server/authz/principal';
|
|
import {
|
|
decideResourceAuthorization,
|
|
isResourceAuthorizationAllowed,
|
|
type ResourceAuthorizationDecision,
|
|
} from '@/lib/server/authz/resource-ownership';
|
|
|
|
function authenticated(
|
|
kind: PrincipalKind,
|
|
principalId: string,
|
|
capabilities: readonly string[] = [],
|
|
): PrincipalResolution {
|
|
return {
|
|
state: 'authenticated',
|
|
principal: {
|
|
principalId,
|
|
kind,
|
|
capabilities,
|
|
authMethod:
|
|
kind === 'device'
|
|
? 'device-proof'
|
|
: kind === 'ops'
|
|
? 'ops-cookie'
|
|
: kind === 'service'
|
|
? 'service-bearer'
|
|
: 'session',
|
|
},
|
|
};
|
|
}
|
|
|
|
describe('provider-neutral request principal', () => {
|
|
test('is explicitly unavailable and ignores spoofable identity headers', async () => {
|
|
const request = new NextRequest('https://server.example/api/generate-classroom/job-1', {
|
|
headers: {
|
|
authorization: 'Bearer attacker-controlled',
|
|
'x-learner-key': 'user:victim',
|
|
'x-openmaic-client': 'victim',
|
|
},
|
|
});
|
|
|
|
await expect(resolveRequestPrincipal(request)).resolves.toEqual({
|
|
state: 'unavailable',
|
|
reason: 'resolver_not_configured',
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('resource ownership policy', () => {
|
|
test('allows an authenticated owner', () => {
|
|
expect(
|
|
decideResourceAuthorization({
|
|
principal: authenticated('user', 'principal-1'),
|
|
ownership: { state: 'owned', ownerPrincipalId: 'principal-1' },
|
|
action: 'delete',
|
|
}),
|
|
).toEqual({ effect: 'allow', reason: 'owner_match' });
|
|
});
|
|
|
|
test('allows a bound device to act for its account and an unbound device for its guest data', () => {
|
|
const boundDevice: PrincipalResolution = {
|
|
state: 'authenticated',
|
|
principal: {
|
|
principalId: 'device-1',
|
|
kind: 'device',
|
|
userId: 'account-1',
|
|
capabilities: [],
|
|
authMethod: 'device-proof',
|
|
},
|
|
};
|
|
|
|
expect(
|
|
decideResourceAuthorization({
|
|
principal: boundDevice,
|
|
ownership: { state: 'owned', ownerPrincipalId: 'account-1' },
|
|
action: 'read',
|
|
}),
|
|
).toEqual({ effect: 'allow', reason: 'owner_match' });
|
|
expect(
|
|
decideResourceAuthorization({
|
|
principal: boundDevice,
|
|
ownership: { state: 'guest-owned', guestPrincipalId: 'device-1' },
|
|
action: 'read',
|
|
}),
|
|
).toEqual({ effect: 'allow', reason: 'guest_match' });
|
|
expect(
|
|
decideResourceAuthorization({
|
|
principal: authenticated('user', 'device-1'),
|
|
ownership: { state: 'guest-owned', guestPrincipalId: 'device-1' },
|
|
action: 'read',
|
|
}),
|
|
).toEqual({ effect: 'deny', reason: 'owner_mismatch' });
|
|
});
|
|
|
|
test('keeps missing and unknown legacy ownership indeterminate', () => {
|
|
const decisions = [
|
|
decideResourceAuthorization({
|
|
principal: { state: 'unavailable', reason: 'resolver_not_configured' },
|
|
ownership: { state: 'legacy-unowned' },
|
|
action: 'read',
|
|
}),
|
|
decideResourceAuthorization({
|
|
principal: authenticated('user', 'principal-1'),
|
|
ownership: { state: 'unknown' },
|
|
action: 'read',
|
|
}),
|
|
decideResourceAuthorization({
|
|
principal: authenticated('user', 'principal-1'),
|
|
ownership: { state: 'owned', ownerPrincipalId: ' ' },
|
|
action: 'read',
|
|
}),
|
|
];
|
|
|
|
expect(decisions).toEqual([
|
|
{ effect: 'indeterminate', reason: 'owner_missing' },
|
|
{ effect: 'indeterminate', reason: 'owner_unknown' },
|
|
{ effect: 'indeterminate', reason: 'owner_unknown' },
|
|
]);
|
|
expect(decisions.every((decision) => !isResourceAuthorizationAllowed(decision))).toBe(true);
|
|
});
|
|
|
|
test('does not elevate ops or service principals by kind alone', () => {
|
|
for (const kind of ['ops', 'service'] as const) {
|
|
expect(
|
|
decideResourceAuthorization({
|
|
principal: authenticated(kind, `${kind}-principal`),
|
|
ownership: { state: 'owned', ownerPrincipalId: 'different-owner' },
|
|
action: 'delete',
|
|
overrideCapability: 'classroom-job:manage:any',
|
|
}),
|
|
).toEqual({ effect: 'deny', reason: 'owner_mismatch' });
|
|
}
|
|
});
|
|
|
|
test('allows only an exact explicit override capability', () => {
|
|
expect(
|
|
decideResourceAuthorization({
|
|
principal: authenticated('ops', 'ops-principal', ['classroom-job:manage:any']),
|
|
ownership: { state: 'owned', ownerPrincipalId: 'different-owner' },
|
|
action: 'delete',
|
|
overrideCapability: 'classroom-job:manage:any',
|
|
}),
|
|
).toEqual({ effect: 'allow', reason: 'explicit_capability' });
|
|
});
|
|
|
|
test('keeps public visibility read-only', () => {
|
|
expect(
|
|
decideResourceAuthorization({
|
|
principal: { state: 'anonymous' },
|
|
ownership: { state: 'public-read' },
|
|
action: 'read',
|
|
}),
|
|
).toEqual({ effect: 'allow', reason: 'public_read' });
|
|
expect(
|
|
decideResourceAuthorization({
|
|
principal: { state: 'anonymous' },
|
|
ownership: { state: 'public-read' },
|
|
action: 'delete',
|
|
}),
|
|
).toEqual({ effect: 'deny', reason: 'action_not_allowed' });
|
|
});
|
|
|
|
test('future enforcement treats anything except explicit allow as denied', () => {
|
|
const decisions: ResourceAuthorizationDecision[] = [
|
|
{ effect: 'allow', reason: 'owner_match' },
|
|
{ effect: 'deny', reason: 'owner_mismatch' },
|
|
{ effect: 'indeterminate', reason: 'resolver_unavailable' },
|
|
];
|
|
|
|
expect(decisions.map(isResourceAuthorizationAllowed)).toEqual([true, false, false]);
|
|
});
|
|
});
|