mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-21 18:08:20 +08:00
feat(ql3): establish 3.0 incubation baseline
This commit is contained in:
+256
@@ -0,0 +1,256 @@
|
||||
import type {
|
||||
AppendProjectRoleBindingResult,
|
||||
ProjectRecord,
|
||||
ProjectRole,
|
||||
ProjectRoleBindingRecord,
|
||||
ProjectRoleBindingState,
|
||||
ProjectStatus,
|
||||
} from './projectPolicy';
|
||||
import type { SecurityPolicyFence, SecuritySubject } from '../security';
|
||||
import type {
|
||||
SecurityAuditRecord,
|
||||
SecurityAuditSink,
|
||||
} from '../audit/securityAudit';
|
||||
|
||||
export const MAX_LOCAL_PROJECT_QUERY_PAGE_SIZE = 64;
|
||||
export const MAX_LOCAL_PROJECT_ROLE_BINDING_QUERY_PAGE_SIZE = 64;
|
||||
|
||||
export interface LocalProjectQueryCursor {
|
||||
readonly slug: string;
|
||||
readonly projectId: string;
|
||||
}
|
||||
|
||||
export type LocalProjectQueryStatus = ProjectStatus | 'all';
|
||||
|
||||
export interface LocalProjectAdministrationAuthorization {
|
||||
readonly authorityProjectId: string;
|
||||
readonly actor: SecuritySubject;
|
||||
readonly fence: SecurityPolicyFence;
|
||||
}
|
||||
|
||||
export interface InspectAuthorizedLocalProjectCommand {
|
||||
readonly projectId: string;
|
||||
readonly authorization: LocalProjectAdministrationAuthorization;
|
||||
readonly audit: SecurityAuditRecord;
|
||||
}
|
||||
|
||||
export interface InspectAuthorizedLocalProjectResult {
|
||||
readonly project: Readonly<ProjectRecord> | null;
|
||||
readonly audit: Readonly<SecurityAuditRecord>;
|
||||
}
|
||||
|
||||
export interface ListAuthorizedLocalProjectsCommand {
|
||||
readonly limit: number;
|
||||
readonly status: LocalProjectQueryStatus;
|
||||
readonly after?: LocalProjectQueryCursor;
|
||||
readonly authorization: LocalProjectAdministrationAuthorization;
|
||||
readonly audit: SecurityAuditRecord;
|
||||
}
|
||||
|
||||
export interface ListAuthorizedLocalProjectsResult {
|
||||
readonly projects: readonly Readonly<ProjectRecord>[];
|
||||
readonly nextCursor: Readonly<LocalProjectQueryCursor> | null;
|
||||
readonly audit: Readonly<SecurityAuditRecord>;
|
||||
}
|
||||
|
||||
export interface LocalProjectRoleBindingQueryCursor {
|
||||
readonly subjectType: SecuritySubject['type'];
|
||||
readonly subjectId: string;
|
||||
}
|
||||
|
||||
export type LocalProjectRoleBindingQueryState = ProjectRoleBindingState | 'all';
|
||||
export type LocalProjectRoleBindingQueryRole = ProjectRole | 'all';
|
||||
|
||||
export interface LocalProjectRoleBindingAdministrationAuthorization {
|
||||
readonly projectId: string;
|
||||
readonly actor: SecuritySubject;
|
||||
readonly fence: SecurityPolicyFence;
|
||||
}
|
||||
|
||||
export interface InspectAuthorizedLocalProjectRoleBindingCommand {
|
||||
readonly target: SecuritySubject;
|
||||
readonly authorization: LocalProjectRoleBindingAdministrationAuthorization;
|
||||
readonly audit: SecurityAuditRecord;
|
||||
}
|
||||
|
||||
export interface InspectAuthorizedLocalProjectRoleBindingResult {
|
||||
readonly binding: Readonly<ProjectRoleBindingRecord> | null;
|
||||
readonly audit: Readonly<SecurityAuditRecord>;
|
||||
}
|
||||
|
||||
export interface ListAuthorizedLocalProjectRoleBindingsCommand {
|
||||
readonly limit: number;
|
||||
readonly state: LocalProjectRoleBindingQueryState;
|
||||
readonly role: LocalProjectRoleBindingQueryRole;
|
||||
readonly after?: LocalProjectRoleBindingQueryCursor;
|
||||
readonly authorization: LocalProjectRoleBindingAdministrationAuthorization;
|
||||
readonly audit: SecurityAuditRecord;
|
||||
}
|
||||
|
||||
export interface ListAuthorizedLocalProjectRoleBindingsResult {
|
||||
readonly bindings: readonly Readonly<ProjectRoleBindingRecord>[];
|
||||
readonly nextCursor: Readonly<LocalProjectRoleBindingQueryCursor> | null;
|
||||
readonly audit: Readonly<SecurityAuditRecord>;
|
||||
}
|
||||
|
||||
export interface AppendAuthorizedProjectRoleBindingCommand {
|
||||
readonly expectedCurrentVersion: number;
|
||||
readonly binding: ProjectRoleBindingRecord;
|
||||
readonly actor: SecuritySubject;
|
||||
readonly fence: SecurityPolicyFence;
|
||||
readonly audit: SecurityAuditRecord;
|
||||
}
|
||||
|
||||
export interface AppendAuthorizedProjectRoleBindingResult
|
||||
extends AppendProjectRoleBindingResult {
|
||||
readonly audit: Readonly<SecurityAuditRecord>;
|
||||
}
|
||||
|
||||
export type LocalProjectAdministrationOperation =
|
||||
| 'create'
|
||||
| 'archive'
|
||||
| 'restore';
|
||||
|
||||
export interface LocalProjectAdministrationMutationRecord {
|
||||
readonly mutationId: string;
|
||||
readonly operation: LocalProjectAdministrationOperation;
|
||||
readonly authorityProjectId: string;
|
||||
readonly project: Readonly<ProjectRecord>;
|
||||
readonly expectedPreviousVersion: number;
|
||||
readonly changedBy: SecuritySubject;
|
||||
readonly createdAtMs: number;
|
||||
}
|
||||
|
||||
interface AppendAuthorizedProjectBaseCommand {
|
||||
readonly authorityProjectId: string;
|
||||
readonly projectId: string;
|
||||
readonly expectedCurrentVersion: number;
|
||||
readonly mutationId: string;
|
||||
readonly actor: SecuritySubject;
|
||||
readonly fence: SecurityPolicyFence;
|
||||
readonly audit: SecurityAuditRecord;
|
||||
readonly occurredAtMs: number;
|
||||
}
|
||||
|
||||
export interface AppendAuthorizedProjectCreateCommand
|
||||
extends AppendAuthorizedProjectBaseCommand {
|
||||
readonly operation: 'create';
|
||||
readonly name: string;
|
||||
readonly slug: string;
|
||||
}
|
||||
|
||||
export interface AppendAuthorizedProjectTransitionCommand
|
||||
extends AppendAuthorizedProjectBaseCommand {
|
||||
readonly operation: 'archive' | 'restore';
|
||||
}
|
||||
|
||||
export type AppendAuthorizedProjectCommand =
|
||||
| AppendAuthorizedProjectCreateCommand
|
||||
| AppendAuthorizedProjectTransitionCommand;
|
||||
|
||||
export interface AppendAuthorizedProjectResult {
|
||||
readonly status: 'inserted' | 'existing';
|
||||
readonly project: Readonly<ProjectRecord>;
|
||||
readonly mutation: Readonly<LocalProjectAdministrationMutationRecord>;
|
||||
readonly initialOwnerBinding: Readonly<ProjectRoleBindingRecord> | null;
|
||||
readonly audit: Readonly<SecurityAuditRecord>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Short-lived policy administration authority. Implementations must revalidate
|
||||
* the actor's Project/RoleBinding fence and atomically commit each query or
|
||||
* mutation with its audit record.
|
||||
*/
|
||||
export interface LocalProjectPolicyAdministrationRepository
|
||||
extends SecurityAuditSink {
|
||||
inspectAuthorizedProjectRoleBinding(
|
||||
command: InspectAuthorizedLocalProjectRoleBindingCommand,
|
||||
): Promise<InspectAuthorizedLocalProjectRoleBindingResult>;
|
||||
listAuthorizedProjectRoleBindings(
|
||||
command: ListAuthorizedLocalProjectRoleBindingsCommand,
|
||||
): Promise<ListAuthorizedLocalProjectRoleBindingsResult>;
|
||||
inspectAuthorizedProject(
|
||||
command: InspectAuthorizedLocalProjectCommand,
|
||||
): Promise<InspectAuthorizedLocalProjectResult>;
|
||||
listAuthorizedProjects(
|
||||
command: ListAuthorizedLocalProjectsCommand,
|
||||
): Promise<ListAuthorizedLocalProjectsResult>;
|
||||
appendAuthorizedProject(
|
||||
command: AppendAuthorizedProjectCommand,
|
||||
): Promise<AppendAuthorizedProjectResult>;
|
||||
appendAuthorizedProjectRoleBinding(
|
||||
command: AppendAuthorizedProjectRoleBindingCommand,
|
||||
): Promise<AppendAuthorizedProjectRoleBindingResult>;
|
||||
}
|
||||
|
||||
export class LocalProjectPolicyAuthorizationFenceConflictError extends Error {
|
||||
readonly code = 'LOCAL_PROJECT_POLICY_AUTHORIZATION_FENCE_CONFLICT';
|
||||
|
||||
constructor() {
|
||||
super('Local Project policy authorization changed');
|
||||
this.name = 'LocalProjectPolicyAuthorizationFenceConflictError';
|
||||
}
|
||||
}
|
||||
|
||||
export class LocalProjectPolicyLastOwnerError extends Error {
|
||||
readonly code = 'LOCAL_PROJECT_POLICY_LAST_OWNER';
|
||||
|
||||
constructor() {
|
||||
super('Local Project must retain at least one active User owner');
|
||||
this.name = 'LocalProjectPolicyLastOwnerError';
|
||||
}
|
||||
}
|
||||
|
||||
export class LocalProjectPolicyOwnerCredentialRequiredError extends Error {
|
||||
readonly code = 'LOCAL_PROJECT_POLICY_OWNER_CREDENTIAL_REQUIRED';
|
||||
|
||||
constructor() {
|
||||
super('A new Local Project owner must have an active credential');
|
||||
this.name = 'LocalProjectPolicyOwnerCredentialRequiredError';
|
||||
}
|
||||
}
|
||||
|
||||
export class LocalProjectPolicyProjectVersionConflictError extends Error {
|
||||
readonly code = 'LOCAL_PROJECT_POLICY_PROJECT_VERSION_CONFLICT';
|
||||
|
||||
constructor() {
|
||||
super('Local Project current version changed');
|
||||
this.name = 'LocalProjectPolicyProjectVersionConflictError';
|
||||
}
|
||||
}
|
||||
|
||||
export class LocalProjectPolicyProjectMutationConflictError extends Error {
|
||||
readonly code = 'LOCAL_PROJECT_POLICY_PROJECT_MUTATION_CONFLICT';
|
||||
|
||||
constructor() {
|
||||
super('Local Project mutation conflicts with its previous request');
|
||||
this.name = 'LocalProjectPolicyProjectMutationConflictError';
|
||||
}
|
||||
}
|
||||
|
||||
export class LocalProjectPolicyProjectIdentityConflictError extends Error {
|
||||
readonly code = 'LOCAL_PROJECT_POLICY_PROJECT_IDENTITY_CONFLICT';
|
||||
|
||||
constructor() {
|
||||
super('Local Project identity or slug is already in use');
|
||||
this.name = 'LocalProjectPolicyProjectIdentityConflictError';
|
||||
}
|
||||
}
|
||||
|
||||
export class LocalProjectPolicyProjectCapacityError extends Error {
|
||||
readonly code = 'LOCAL_PROJECT_POLICY_PROJECT_CAPACITY_EXCEEDED';
|
||||
|
||||
constructor() {
|
||||
super('Local Project capacity is exhausted');
|
||||
this.name = 'LocalProjectPolicyProjectCapacityError';
|
||||
}
|
||||
}
|
||||
|
||||
export class LocalProjectPolicyAuthorityProjectProtectedError extends Error {
|
||||
readonly code = 'LOCAL_PROJECT_POLICY_AUTHORITY_PROJECT_PROTECTED';
|
||||
|
||||
constructor() {
|
||||
super('Local instance authority Project cannot be archived');
|
||||
this.name = 'LocalProjectPolicyAuthorityProjectProtectedError';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,489 @@
|
||||
import {
|
||||
SECURITY_SUBJECT_TYPES,
|
||||
normalizeSecurityPolicyDecision,
|
||||
type SecurityPolicyDecision,
|
||||
type SecurityPrincipal,
|
||||
type SecuritySubject,
|
||||
} from '../security';
|
||||
|
||||
export const PROJECT_STATUSES = ['active', 'archived'] as const;
|
||||
export const PROJECT_ROLES = ['owner', 'admin', 'operator', 'viewer'] as const;
|
||||
export const PROJECT_ROLE_BINDING_STATES = ['active', 'revoked'] as const;
|
||||
export const STATIC_PROJECT_PERMISSIONS = [
|
||||
'project.read',
|
||||
'project.manage',
|
||||
'task.read',
|
||||
'task.create',
|
||||
'task.update',
|
||||
'task.delete',
|
||||
'trigger.read',
|
||||
'trigger.create',
|
||||
'trigger.update',
|
||||
'run.read',
|
||||
'run.start',
|
||||
'run.stop',
|
||||
'run.retry',
|
||||
'model.invoke',
|
||||
'artifact.read',
|
||||
'approval.read',
|
||||
'package.manage',
|
||||
'secret.use',
|
||||
'secret.manage',
|
||||
'worker.manage',
|
||||
'policy.manage',
|
||||
'approval.decide',
|
||||
'approval.recover',
|
||||
] as const;
|
||||
|
||||
export type ProjectStatus = (typeof PROJECT_STATUSES)[number];
|
||||
export type ProjectRole = (typeof PROJECT_ROLES)[number];
|
||||
export type ProjectRoleBindingState =
|
||||
(typeof PROJECT_ROLE_BINDING_STATES)[number];
|
||||
export type StaticProjectPermission =
|
||||
(typeof STATIC_PROJECT_PERMISSIONS)[number];
|
||||
export type ProjectPermission = StaticProjectPermission | `tool.call:${string}`;
|
||||
|
||||
export interface ProjectRecord {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
readonly slug: string;
|
||||
readonly status: ProjectStatus;
|
||||
readonly version: number;
|
||||
readonly createdAtMs: number;
|
||||
readonly updatedAtMs: number;
|
||||
}
|
||||
|
||||
export interface ProjectRoleBindingRecord {
|
||||
readonly projectId: string;
|
||||
readonly subject: SecuritySubject;
|
||||
readonly version: number;
|
||||
readonly state: ProjectRoleBindingState;
|
||||
readonly role?: ProjectRole;
|
||||
readonly mutationId: string;
|
||||
readonly changedBy: SecuritySubject;
|
||||
readonly createdAtMs: number;
|
||||
}
|
||||
|
||||
export interface ProjectPolicySnapshot {
|
||||
readonly project: Readonly<ProjectRecord>;
|
||||
readonly binding?: Readonly<ProjectRoleBindingRecord>;
|
||||
}
|
||||
|
||||
export interface ProjectPolicyRequest {
|
||||
readonly subject: SecuritySubject;
|
||||
readonly projectId: string;
|
||||
readonly permission: ProjectPermission;
|
||||
}
|
||||
|
||||
export interface AppendProjectRoleBindingCommand {
|
||||
readonly expectedCurrentVersion: number;
|
||||
readonly binding: ProjectRoleBindingRecord;
|
||||
}
|
||||
|
||||
export interface AppendProjectRoleBindingResult {
|
||||
readonly status: 'inserted' | 'existing';
|
||||
readonly binding: Readonly<ProjectRoleBindingRecord>;
|
||||
}
|
||||
|
||||
export interface ProjectPolicyRepository {
|
||||
resolve(
|
||||
projectId: string,
|
||||
subject: Readonly<SecuritySubject>,
|
||||
): Promise<Readonly<ProjectPolicySnapshot> | null>;
|
||||
append(
|
||||
command: AppendProjectRoleBindingCommand,
|
||||
): Promise<AppendProjectRoleBindingResult>;
|
||||
}
|
||||
|
||||
export const MAX_PROJECT_ROLE_BINDING_VERSION = 2_147_483_647;
|
||||
|
||||
export class InvalidProjectPolicyValueError extends TypeError {
|
||||
constructor(message: string) {
|
||||
super(`Project policy contract is invalid: ${message}`);
|
||||
this.name = 'InvalidProjectPolicyValueError';
|
||||
}
|
||||
}
|
||||
|
||||
export class ProjectPolicyUnavailableError extends Error {
|
||||
readonly code = 'PROJECT_POLICY_UNAVAILABLE';
|
||||
constructor() {
|
||||
super('Project policy is unavailable');
|
||||
this.name = 'ProjectPolicyUnavailableError';
|
||||
}
|
||||
}
|
||||
|
||||
export class ProjectPolicyProjectNotFoundError extends Error {
|
||||
readonly code = 'PROJECT_NOT_FOUND';
|
||||
constructor() {
|
||||
super('Project does not exist');
|
||||
this.name = 'ProjectPolicyProjectNotFoundError';
|
||||
}
|
||||
}
|
||||
|
||||
export class ProjectRoleBindingVersionConflictError extends Error {
|
||||
readonly code = 'PROJECT_ROLE_BINDING_VERSION_CONFLICT';
|
||||
constructor() {
|
||||
super('Project role binding current version changed');
|
||||
this.name = 'ProjectRoleBindingVersionConflictError';
|
||||
}
|
||||
}
|
||||
|
||||
export class ProjectRoleBindingMutationConflictError extends Error {
|
||||
readonly code = 'PROJECT_ROLE_BINDING_MUTATION_CONFLICT';
|
||||
constructor() {
|
||||
super('Project role binding mutation conflicts with its previous request');
|
||||
this.name = 'ProjectRoleBindingMutationConflictError';
|
||||
}
|
||||
}
|
||||
|
||||
const CONTROL_PATTERN = /[\u0000-\u001f\u007f]/;
|
||||
const SLUG_PATTERN = /^[a-z0-9](?:[a-z0-9-]{0,126}[a-z0-9])?$/;
|
||||
const MUTATION_PATTERN = /^[A-Za-z0-9._:-]{1,64}$/;
|
||||
const TOOL_PERMISSION_PATTERN =
|
||||
/^tool\.call:[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
||||
|
||||
function exactKeys(
|
||||
value: object,
|
||||
expected: readonly string[],
|
||||
name: string,
|
||||
): void {
|
||||
const keys = Object.keys(value).sort();
|
||||
const canonical = [...expected].sort();
|
||||
if (
|
||||
keys.length !== canonical.length ||
|
||||
keys.some((key, index) => key !== canonical[index])
|
||||
) {
|
||||
throw new InvalidProjectPolicyValueError(`${name} shape is invalid`);
|
||||
}
|
||||
}
|
||||
|
||||
function boundedText(value: string, maximum: number, name: string): string {
|
||||
if (
|
||||
typeof value !== 'string' ||
|
||||
value.length < 1 ||
|
||||
value.length > maximum ||
|
||||
CONTROL_PATTERN.test(value)
|
||||
) {
|
||||
throw new InvalidProjectPolicyValueError(`${name} is invalid`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function timestamp(value: number, name: string): number {
|
||||
if (!Number.isSafeInteger(value) || value < 0) {
|
||||
throw new InvalidProjectPolicyValueError(`${name} is invalid`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function version(value: number, name: string): number {
|
||||
if (
|
||||
!Number.isSafeInteger(value) ||
|
||||
value < 1 ||
|
||||
value > MAX_PROJECT_ROLE_BINDING_VERSION
|
||||
) {
|
||||
throw new InvalidProjectPolicyValueError(`${name} is invalid`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function normalizeProjectPolicySubject(
|
||||
value: SecuritySubject,
|
||||
): Readonly<SecuritySubject> {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw new InvalidProjectPolicyValueError('subject must be an object');
|
||||
}
|
||||
exactKeys(value, ['type', 'id'], 'subject');
|
||||
if (!SECURITY_SUBJECT_TYPES.includes(value.type)) {
|
||||
throw new InvalidProjectPolicyValueError('subject type is invalid');
|
||||
}
|
||||
return Object.freeze({
|
||||
type: value.type,
|
||||
id: boundedText(value.id, 255, 'subject id'),
|
||||
});
|
||||
}
|
||||
|
||||
export function assertProjectPolicyProjectId(value: string): void {
|
||||
boundedText(value, 128, 'project id');
|
||||
}
|
||||
|
||||
export function normalizeProjectPermission(value: string): ProjectPermission {
|
||||
if (
|
||||
STATIC_PROJECT_PERMISSIONS.includes(value as StaticProjectPermission) ||
|
||||
(typeof value === 'string' && TOOL_PERMISSION_PATTERN.test(value))
|
||||
) {
|
||||
return value as ProjectPermission;
|
||||
}
|
||||
throw new InvalidProjectPolicyValueError('permission is invalid');
|
||||
}
|
||||
|
||||
export function normalizeProjectRecord(
|
||||
value: ProjectRecord,
|
||||
): Readonly<ProjectRecord> {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw new InvalidProjectPolicyValueError('project must be an object');
|
||||
}
|
||||
exactKeys(
|
||||
value,
|
||||
['id', 'name', 'slug', 'status', 'version', 'createdAtMs', 'updatedAtMs'],
|
||||
'project',
|
||||
);
|
||||
const createdAtMs = timestamp(value.createdAtMs, 'project createdAtMs');
|
||||
const updatedAtMs = timestamp(value.updatedAtMs, 'project updatedAtMs');
|
||||
if (updatedAtMs < createdAtMs) {
|
||||
throw new InvalidProjectPolicyValueError('project timestamps are invalid');
|
||||
}
|
||||
if (!PROJECT_STATUSES.includes(value.status)) {
|
||||
throw new InvalidProjectPolicyValueError('project status is invalid');
|
||||
}
|
||||
if (!SLUG_PATTERN.test(value.slug)) {
|
||||
throw new InvalidProjectPolicyValueError('project slug is invalid');
|
||||
}
|
||||
assertProjectPolicyProjectId(value.id);
|
||||
return Object.freeze({
|
||||
id: value.id,
|
||||
name: boundedText(value.name, 255, 'project name'),
|
||||
slug: value.slug,
|
||||
status: value.status,
|
||||
version: version(value.version, 'project version'),
|
||||
createdAtMs,
|
||||
updatedAtMs,
|
||||
});
|
||||
}
|
||||
|
||||
export function normalizeProjectRoleBinding(
|
||||
value: ProjectRoleBindingRecord,
|
||||
): Readonly<ProjectRoleBindingRecord> {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw new InvalidProjectPolicyValueError('role binding must be an object');
|
||||
}
|
||||
exactKeys(
|
||||
value,
|
||||
value.state === 'active'
|
||||
? [
|
||||
'projectId',
|
||||
'subject',
|
||||
'version',
|
||||
'state',
|
||||
'role',
|
||||
'mutationId',
|
||||
'changedBy',
|
||||
'createdAtMs',
|
||||
]
|
||||
: [
|
||||
'projectId',
|
||||
'subject',
|
||||
'version',
|
||||
'state',
|
||||
'mutationId',
|
||||
'changedBy',
|
||||
'createdAtMs',
|
||||
],
|
||||
'role binding',
|
||||
);
|
||||
if (!PROJECT_ROLE_BINDING_STATES.includes(value.state)) {
|
||||
throw new InvalidProjectPolicyValueError('role binding state is invalid');
|
||||
}
|
||||
if (
|
||||
(value.state === 'active' &&
|
||||
(!value.role || !PROJECT_ROLES.includes(value.role))) ||
|
||||
(value.state === 'revoked' && value.role !== undefined)
|
||||
) {
|
||||
throw new InvalidProjectPolicyValueError('role binding role is invalid');
|
||||
}
|
||||
if (
|
||||
typeof value.mutationId !== 'string' ||
|
||||
!MUTATION_PATTERN.test(value.mutationId)
|
||||
) {
|
||||
throw new InvalidProjectPolicyValueError('mutation id is invalid');
|
||||
}
|
||||
assertProjectPolicyProjectId(value.projectId);
|
||||
return Object.freeze({
|
||||
projectId: value.projectId,
|
||||
subject: normalizeProjectPolicySubject(value.subject),
|
||||
version: version(value.version, 'role binding version'),
|
||||
state: value.state,
|
||||
...(value.role ? { role: value.role } : {}),
|
||||
mutationId: value.mutationId,
|
||||
changedBy: normalizeProjectPolicySubject(value.changedBy),
|
||||
createdAtMs: timestamp(value.createdAtMs, 'role binding createdAtMs'),
|
||||
});
|
||||
}
|
||||
|
||||
export function normalizeProjectPolicySnapshot(
|
||||
value: ProjectPolicySnapshot,
|
||||
): Readonly<ProjectPolicySnapshot> {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw new InvalidProjectPolicyValueError(
|
||||
'policy snapshot must be an object',
|
||||
);
|
||||
}
|
||||
exactKeys(
|
||||
value,
|
||||
value.binding ? ['project', 'binding'] : ['project'],
|
||||
'snapshot',
|
||||
);
|
||||
const project = normalizeProjectRecord(value.project);
|
||||
const binding = value.binding
|
||||
? normalizeProjectRoleBinding(value.binding)
|
||||
: undefined;
|
||||
if (binding && binding.projectId !== project.id) {
|
||||
throw new InvalidProjectPolicyValueError(
|
||||
'binding belongs to another project',
|
||||
);
|
||||
}
|
||||
return Object.freeze({ project, ...(binding ? { binding } : {}) });
|
||||
}
|
||||
|
||||
export function assertExpectedProjectRoleBindingVersion(value: number): void {
|
||||
if (
|
||||
!Number.isSafeInteger(value) ||
|
||||
value < 0 ||
|
||||
value >= MAX_PROJECT_ROLE_BINDING_VERSION
|
||||
) {
|
||||
throw new InvalidProjectPolicyValueError(
|
||||
'expected role binding version is invalid',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const READ_ONLY_PERMISSIONS = new Set<ProjectPermission>([
|
||||
'project.read',
|
||||
'task.read',
|
||||
'trigger.read',
|
||||
'run.read',
|
||||
'artifact.read',
|
||||
'approval.read',
|
||||
]);
|
||||
const OPERATOR_PERMISSIONS = new Set<ProjectPermission>([
|
||||
...READ_ONLY_PERMISSIONS,
|
||||
'task.create',
|
||||
'task.update',
|
||||
'trigger.create',
|
||||
'trigger.update',
|
||||
'run.start',
|
||||
'run.stop',
|
||||
'run.retry',
|
||||
'model.invoke',
|
||||
'secret.use',
|
||||
]);
|
||||
const AGENT_APPROVAL_PERMISSIONS = new Set<ProjectPermission>([
|
||||
'project.manage',
|
||||
'task.create',
|
||||
'task.update',
|
||||
'task.delete',
|
||||
'trigger.create',
|
||||
'trigger.update',
|
||||
'run.start',
|
||||
'run.stop',
|
||||
'run.retry',
|
||||
'model.invoke',
|
||||
'secret.use',
|
||||
'secret.manage',
|
||||
'package.manage',
|
||||
'worker.manage',
|
||||
'policy.manage',
|
||||
'approval.decide',
|
||||
]);
|
||||
|
||||
function roleAllows(role: ProjectRole, permission: ProjectPermission): boolean {
|
||||
if (role === 'owner') return true;
|
||||
if (role === 'admin')
|
||||
return (
|
||||
permission.startsWith('tool.call:') || permission !== 'project.manage'
|
||||
);
|
||||
if (role === 'operator')
|
||||
return (
|
||||
permission.startsWith('tool.call:') ||
|
||||
OPERATOR_PERMISSIONS.has(permission)
|
||||
);
|
||||
return READ_ONLY_PERMISSIONS.has(permission);
|
||||
}
|
||||
|
||||
/** Evaluates one immutable Project/RoleBinding snapshot and returns its fence. */
|
||||
export class ProjectPolicyEngine {
|
||||
constructor(
|
||||
private readonly repository: Pick<ProjectPolicyRepository, 'resolve'>,
|
||||
) {
|
||||
if (!repository || typeof repository.resolve !== 'function') {
|
||||
throw new TypeError('Project policy repository is invalid');
|
||||
}
|
||||
}
|
||||
|
||||
async decide(request: ProjectPolicyRequest): Promise<SecurityPolicyDecision> {
|
||||
if (!request || typeof request !== 'object' || Array.isArray(request)) {
|
||||
throw new InvalidProjectPolicyValueError('request must be an object');
|
||||
}
|
||||
exactKeys(request, ['subject', 'projectId', 'permission'], 'request');
|
||||
const subject = normalizeProjectPolicySubject(request.subject);
|
||||
assertProjectPolicyProjectId(request.projectId);
|
||||
const projectId = request.projectId;
|
||||
const permission = normalizeProjectPermission(request.permission);
|
||||
let snapshot: Readonly<ProjectPolicySnapshot> | null;
|
||||
try {
|
||||
const resolved = await this.repository.resolve(projectId, subject);
|
||||
snapshot = resolved ? normalizeProjectPolicySnapshot(resolved) : null;
|
||||
} catch {
|
||||
throw new ProjectPolicyUnavailableError();
|
||||
}
|
||||
if (!snapshot) {
|
||||
return normalizeSecurityPolicyDecision({
|
||||
effect: 'deny',
|
||||
reasons: ['project_not_found'],
|
||||
fence: null,
|
||||
});
|
||||
}
|
||||
const fence = {
|
||||
projectVersion: snapshot.project.version,
|
||||
bindingVersion: snapshot.binding?.version ?? null,
|
||||
};
|
||||
if (!snapshot.binding || snapshot.binding.state === 'revoked') {
|
||||
return normalizeSecurityPolicyDecision({
|
||||
effect: 'deny',
|
||||
reasons: ['subject_unbound'],
|
||||
fence,
|
||||
});
|
||||
}
|
||||
if (
|
||||
snapshot.project.status === 'archived' &&
|
||||
!READ_ONLY_PERMISSIONS.has(permission)
|
||||
) {
|
||||
return normalizeSecurityPolicyDecision({
|
||||
effect: 'deny',
|
||||
reasons: ['project_archived'],
|
||||
fence,
|
||||
});
|
||||
}
|
||||
if (!roleAllows(snapshot.binding.role!, permission)) {
|
||||
return normalizeSecurityPolicyDecision({
|
||||
effect: 'deny',
|
||||
reasons: ['permission_missing'],
|
||||
fence,
|
||||
});
|
||||
}
|
||||
if (
|
||||
subject.type === 'agent' &&
|
||||
(permission.startsWith('tool.call:') ||
|
||||
AGENT_APPROVAL_PERMISSIONS.has(permission))
|
||||
) {
|
||||
return normalizeSecurityPolicyDecision({
|
||||
effect: 'require_approval',
|
||||
reasons: ['agent_action_requires_approval'],
|
||||
fence,
|
||||
});
|
||||
}
|
||||
return normalizeSecurityPolicyDecision({
|
||||
effect: 'allow',
|
||||
reasons: ['role_grant'],
|
||||
fence,
|
||||
});
|
||||
}
|
||||
|
||||
async authorize(
|
||||
principal: Readonly<SecurityPrincipal>,
|
||||
projectId: string,
|
||||
permission: ProjectPermission,
|
||||
): Promise<SecurityPolicyDecision> {
|
||||
return this.decide({ subject: principal.subject, projectId, permission });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user