mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-21 00:36:13 +08:00
372 lines
10 KiB
TypeScript
372 lines
10 KiB
TypeScript
export const POLICY_SUBJECT_TYPES = [
|
|
'user',
|
|
'api_app',
|
|
'mcp_client',
|
|
'agent',
|
|
'system',
|
|
'worker',
|
|
] as const;
|
|
|
|
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',
|
|
'run.read',
|
|
'run.start',
|
|
'run.stop',
|
|
'run.retry',
|
|
'artifact.read',
|
|
'secret.use',
|
|
'secret.manage',
|
|
'worker.manage',
|
|
'policy.manage',
|
|
'approval.decide',
|
|
'approval.recover',
|
|
] as const;
|
|
|
|
export type PolicySubjectType = (typeof POLICY_SUBJECT_TYPES)[number];
|
|
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 PolicySubject {
|
|
type: PolicySubjectType;
|
|
id: string;
|
|
}
|
|
|
|
export interface ProjectRecord {
|
|
id: string;
|
|
name: string;
|
|
slug: string;
|
|
status: ProjectStatus;
|
|
version: number;
|
|
createdAtMs: number;
|
|
updatedAtMs: number;
|
|
}
|
|
|
|
export interface ProjectRoleBindingRecord {
|
|
projectId: string;
|
|
subject: PolicySubject;
|
|
version: number;
|
|
state: ProjectRoleBindingState;
|
|
role?: ProjectRole;
|
|
mutationId: string;
|
|
changedBy: PolicySubject;
|
|
createdAtMs: number;
|
|
}
|
|
|
|
export interface ProjectPolicySnapshot {
|
|
project: Readonly<ProjectRecord>;
|
|
binding?: Readonly<ProjectRoleBindingRecord>;
|
|
}
|
|
|
|
export type ProjectPolicyEffect = 'allow' | 'deny' | 'require_approval';
|
|
|
|
export interface ProjectPolicyDecision {
|
|
effect: ProjectPolicyEffect;
|
|
reasons: readonly string[];
|
|
}
|
|
|
|
export interface ProjectPolicyFence {
|
|
projectVersion: number;
|
|
bindingVersion: number | null;
|
|
}
|
|
|
|
export interface ProjectPolicyDecisionWithFence {
|
|
decision: Readonly<ProjectPolicyDecision>;
|
|
fence: Readonly<ProjectPolicyFence> | null;
|
|
}
|
|
|
|
export interface ProjectPolicyRequest {
|
|
subject: PolicySubject;
|
|
projectId: string;
|
|
permission: ProjectPermission;
|
|
}
|
|
|
|
export const MAX_POLICY_SUBJECT_ID_LENGTH = 255;
|
|
export const MAX_PROJECT_ID_LENGTH = 128;
|
|
export const MAX_PROJECT_NAME_LENGTH = 255;
|
|
export const MAX_PROJECT_SLUG_LENGTH = 128;
|
|
export const MAX_PROJECT_ROLE_BINDING_VERSION = 2_147_483_647;
|
|
export const MAX_PROJECT_POLICY_MUTATION_ID_LENGTH = 64;
|
|
export const MAX_PROJECT_PERMISSION_LENGTH = 255;
|
|
|
|
const IDENTIFIER_CONTROL_PATTERN = /[\u0000-\u001f\u007f]/;
|
|
const PROJECT_SLUG_PATTERN = /^[a-z0-9](?:[a-z0-9-]{0,126}[a-z0-9])?$/;
|
|
const MUTATION_ID_PATTERN = /^[A-Za-z0-9._:-]+$/;
|
|
const TOOL_PERMISSION_PATTERN =
|
|
/^tool\.call:[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
|
|
|
export class InvalidProjectPolicyValueError extends TypeError {
|
|
constructor(message: string) {
|
|
super(`Project policy value is invalid: ${message}`);
|
|
this.name = 'InvalidProjectPolicyValueError';
|
|
}
|
|
}
|
|
|
|
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 does not match its previous request');
|
|
this.name = 'ProjectRoleBindingMutationConflictError';
|
|
}
|
|
}
|
|
|
|
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';
|
|
}
|
|
}
|
|
|
|
function assertBoundedIdentifier(
|
|
name: string,
|
|
value: string,
|
|
maximum: number,
|
|
): void {
|
|
if (
|
|
typeof value !== 'string' ||
|
|
value.length < 1 ||
|
|
value.length > maximum ||
|
|
IDENTIFIER_CONTROL_PATTERN.test(value)
|
|
) {
|
|
throw new InvalidProjectPolicyValueError(`${name} is invalid`);
|
|
}
|
|
}
|
|
|
|
function assertTimestamp(name: string, value: number): void {
|
|
if (!Number.isSafeInteger(value) || value < 0) {
|
|
throw new InvalidProjectPolicyValueError(`${name} is invalid`);
|
|
}
|
|
}
|
|
|
|
function assertExactKeys(
|
|
name: string,
|
|
value: object,
|
|
expected: readonly 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`);
|
|
}
|
|
}
|
|
|
|
export function assertProjectPolicyProjectId(value: string): void {
|
|
assertBoundedIdentifier('projectId', value, MAX_PROJECT_ID_LENGTH);
|
|
}
|
|
|
|
export function normalizePolicySubject(
|
|
value: PolicySubject,
|
|
): Readonly<PolicySubject> {
|
|
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
throw new InvalidProjectPolicyValueError('subject must be an object');
|
|
}
|
|
assertExactKeys('subject', value, ['type', 'id']);
|
|
if (!POLICY_SUBJECT_TYPES.includes(value.type)) {
|
|
throw new InvalidProjectPolicyValueError('subject type is invalid');
|
|
}
|
|
assertBoundedIdentifier('subject id', value.id, MAX_POLICY_SUBJECT_ID_LENGTH);
|
|
return Object.freeze({ type: value.type, id: value.id });
|
|
}
|
|
|
|
export function normalizeProjectPermission(value: string): ProjectPermission {
|
|
if (
|
|
typeof value !== 'string' ||
|
|
value.length < 1 ||
|
|
value.length > MAX_PROJECT_PERMISSION_LENGTH
|
|
) {
|
|
throw new InvalidProjectPolicyValueError('permission is invalid');
|
|
}
|
|
if (
|
|
STATIC_PROJECT_PERMISSIONS.includes(value as StaticProjectPermission) ||
|
|
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');
|
|
}
|
|
assertExactKeys('Project', value, [
|
|
'id',
|
|
'name',
|
|
'slug',
|
|
'status',
|
|
'version',
|
|
'createdAtMs',
|
|
'updatedAtMs',
|
|
]);
|
|
assertProjectPolicyProjectId(value.id);
|
|
assertBoundedIdentifier('Project name', value.name, MAX_PROJECT_NAME_LENGTH);
|
|
if (
|
|
typeof value.slug !== 'string' ||
|
|
value.slug.length > MAX_PROJECT_SLUG_LENGTH ||
|
|
!PROJECT_SLUG_PATTERN.test(value.slug)
|
|
) {
|
|
throw new InvalidProjectPolicyValueError('Project slug is invalid');
|
|
}
|
|
if (!PROJECT_STATUSES.includes(value.status)) {
|
|
throw new InvalidProjectPolicyValueError('Project status is invalid');
|
|
}
|
|
if (
|
|
!Number.isSafeInteger(value.version) ||
|
|
value.version < 1 ||
|
|
value.version > MAX_PROJECT_ROLE_BINDING_VERSION
|
|
) {
|
|
throw new InvalidProjectPolicyValueError('Project version is invalid');
|
|
}
|
|
assertTimestamp('Project createdAtMs', value.createdAtMs);
|
|
assertTimestamp('Project updatedAtMs', value.updatedAtMs);
|
|
if (value.updatedAtMs < value.createdAtMs) {
|
|
throw new InvalidProjectPolicyValueError('Project timestamps are invalid');
|
|
}
|
|
return Object.freeze({ ...value });
|
|
}
|
|
|
|
export function normalizeProjectRoleBindingRecord(
|
|
value: ProjectRoleBindingRecord,
|
|
): Readonly<ProjectRoleBindingRecord> {
|
|
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
throw new InvalidProjectPolicyValueError(
|
|
'Project role binding must be an object',
|
|
);
|
|
}
|
|
assertExactKeys(
|
|
'Project role binding',
|
|
value,
|
|
value.state === 'active'
|
|
? [
|
|
'projectId',
|
|
'subject',
|
|
'version',
|
|
'state',
|
|
'role',
|
|
'mutationId',
|
|
'changedBy',
|
|
'createdAtMs',
|
|
]
|
|
: [
|
|
'projectId',
|
|
'subject',
|
|
'version',
|
|
'state',
|
|
'mutationId',
|
|
'changedBy',
|
|
'createdAtMs',
|
|
],
|
|
);
|
|
assertProjectPolicyProjectId(value.projectId);
|
|
const subject = normalizePolicySubject(value.subject);
|
|
const changedBy = normalizePolicySubject(value.changedBy);
|
|
if (
|
|
!Number.isSafeInteger(value.version) ||
|
|
value.version < 1 ||
|
|
value.version > MAX_PROJECT_ROLE_BINDING_VERSION
|
|
) {
|
|
throw new InvalidProjectPolicyValueError(
|
|
'Project role binding version is invalid',
|
|
);
|
|
}
|
|
if (!PROJECT_ROLE_BINDING_STATES.includes(value.state)) {
|
|
throw new InvalidProjectPolicyValueError(
|
|
'Project 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(
|
|
'Project role binding role is invalid',
|
|
);
|
|
}
|
|
if (
|
|
typeof value.mutationId !== 'string' ||
|
|
value.mutationId.length < 1 ||
|
|
value.mutationId.length > MAX_PROJECT_POLICY_MUTATION_ID_LENGTH ||
|
|
!MUTATION_ID_PATTERN.test(value.mutationId)
|
|
) {
|
|
throw new InvalidProjectPolicyValueError(
|
|
'Project role binding mutationId is invalid',
|
|
);
|
|
}
|
|
assertTimestamp('Project role binding createdAtMs', value.createdAtMs);
|
|
return Object.freeze({
|
|
projectId: value.projectId,
|
|
subject,
|
|
version: value.version,
|
|
state: value.state,
|
|
...(value.role ? { role: value.role } : {}),
|
|
mutationId: value.mutationId,
|
|
changedBy,
|
|
createdAtMs: value.createdAtMs,
|
|
});
|
|
}
|
|
|
|
export function normalizeProjectPolicySnapshot(
|
|
value: ProjectPolicySnapshot,
|
|
): Readonly<ProjectPolicySnapshot> {
|
|
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
throw new InvalidProjectPolicyValueError(
|
|
'Project policy snapshot must be an object',
|
|
);
|
|
}
|
|
assertExactKeys(
|
|
'Project policy snapshot',
|
|
value,
|
|
value.binding ? ['project', 'binding'] : ['project'],
|
|
);
|
|
const project = normalizeProjectRecord(value.project);
|
|
const binding = value.binding
|
|
? normalizeProjectRoleBindingRecord(value.binding)
|
|
: undefined;
|
|
if (binding && binding.projectId !== project.id) {
|
|
throw new InvalidProjectPolicyValueError(
|
|
'Project policy snapshot binding is misplaced',
|
|
);
|
|
}
|
|
return Object.freeze({ project, ...(binding ? { binding } : {}) });
|
|
}
|