148 lines
4.9 KiB
TypeScript
148 lines
4.9 KiB
TypeScript
/**
|
|
* Federation grant scope schema and validator.
|
|
*
|
|
* Source of truth: docs/federation/PRD.md §8.1
|
|
*
|
|
* This module is intentionally pure — no DB, no NestJS, no CA wiring.
|
|
* It is reusable from grant CRUD (M2-06) and scope enforcement (M3+).
|
|
*/
|
|
|
|
import { z } from 'zod';
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Allowlist of federation resources (canonical — M3+ will extend this list)
|
|
// ---------------------------------------------------------------------------
|
|
export const FEDERATION_RESOURCE_VALUES = [
|
|
'tasks',
|
|
'notes',
|
|
'memory',
|
|
'credentials',
|
|
'api_keys',
|
|
] as const;
|
|
|
|
export type FederationResource = (typeof FEDERATION_RESOURCE_VALUES)[number];
|
|
|
|
/**
|
|
* Sensitive resources require explicit admin approval (PRD §8.4).
|
|
* The parser warns when these appear in `resources`; M2-06 grant CRUD
|
|
* will add a hard gate on top of this warning.
|
|
*/
|
|
const SENSITIVE_RESOURCES: ReadonlySet<FederationResource> = new Set(['credentials', 'api_keys']);
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Sub-schemas
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const ResourceArraySchema = z
|
|
.array(z.enum(FEDERATION_RESOURCE_VALUES))
|
|
.nonempty({ message: 'resources must contain at least one value' })
|
|
.refine((arr) => new Set(arr).size === arr.length, {
|
|
message: 'resources must not contain duplicate values',
|
|
});
|
|
|
|
const ResourceFilterSchema = z.object({
|
|
include_teams: z.array(z.string()).optional(),
|
|
include_personal: z.boolean().default(true),
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Top-level schema
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export const FederationScopeSchema = z
|
|
.object({
|
|
resources: ResourceArraySchema,
|
|
|
|
excluded_resources: z
|
|
.array(z.enum(FEDERATION_RESOURCE_VALUES))
|
|
.default([])
|
|
.refine((arr) => new Set(arr).size === arr.length, {
|
|
message: 'excluded_resources must not contain duplicate values',
|
|
}),
|
|
|
|
filters: z.record(z.string(), ResourceFilterSchema).optional(),
|
|
|
|
max_rows_per_query: z
|
|
.number()
|
|
.int({ message: 'max_rows_per_query must be an integer' })
|
|
.min(1, { message: 'max_rows_per_query must be at least 1' })
|
|
.max(10000, { message: 'max_rows_per_query must be at most 10000' }),
|
|
})
|
|
.superRefine((data, ctx) => {
|
|
const resourceSet = new Set(data.resources);
|
|
|
|
// Intersection guard: a resource cannot be both granted and excluded
|
|
for (const r of data.excluded_resources) {
|
|
if (resourceSet.has(r)) {
|
|
ctx.addIssue({
|
|
code: z.ZodIssueCode.custom,
|
|
message: `Resource "${r}" appears in both resources and excluded_resources`,
|
|
path: ['excluded_resources'],
|
|
});
|
|
}
|
|
}
|
|
|
|
// Filter keys must be a subset of resources
|
|
if (data.filters) {
|
|
for (const key of Object.keys(data.filters)) {
|
|
if (!resourceSet.has(key as FederationResource)) {
|
|
ctx.addIssue({
|
|
code: z.ZodIssueCode.custom,
|
|
message: `filters key "${key}" references a resource not present in resources`,
|
|
path: ['filters', key],
|
|
});
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
export type FederationScope = z.infer<typeof FederationScopeSchema>;
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Error class
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export class FederationScopeError extends Error {
|
|
constructor(message: string) {
|
|
super(message);
|
|
this.name = 'FederationScopeError';
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Typed parser
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Parse and validate an unknown value as a FederationScope.
|
|
*
|
|
* Throws `FederationScopeError` with aggregated Zod issues on failure.
|
|
*
|
|
* Emits `console.warn` when sensitive resources (`credentials`, `api_keys`)
|
|
* are present in `resources` — per PRD §8.4, these require explicit admin
|
|
* approval. M2-06 grant CRUD will add a hard gate on top of this warning.
|
|
*/
|
|
export function parseFederationScope(input: unknown): FederationScope {
|
|
const result = FederationScopeSchema.safeParse(input);
|
|
|
|
if (!result.success) {
|
|
const issues = result.error.issues
|
|
.map((e) => ` - [${e.path.join('.') || 'root'}] ${e.message}`)
|
|
.join('\n');
|
|
throw new FederationScopeError(`Invalid federation scope:\n${issues}`);
|
|
}
|
|
|
|
const scope = result.data;
|
|
|
|
// Sentinel warning for sensitive resources (PRD §8.4)
|
|
for (const resource of scope.resources) {
|
|
if (SENSITIVE_RESOURCES.has(resource)) {
|
|
console.warn(
|
|
`[FederationScope] WARNING: scope grants sensitive resource "${resource}". Per PRD §8.4 this requires explicit admin approval and is logged.`,
|
|
);
|
|
}
|
|
}
|
|
|
|
return scope;
|
|
}
|