Add identity access controls
This commit is contained in:
1 parent
276929b855
commit
1d7757c88b
21 files changed
+607
-8
No files matched your search
@@ -15,6 +15,7 @@ import { MigrationService } from "../services/migration-service";
|
||||
import { OnboardingService } from "../services/onboarding-service";
|
||||
import { ComplianceService } from "../services/compliance-service";
|
||||
import { SearchService } from "../services/search-service";
|
||||
import { IdentityAccessService } from "../services/identity-access-service";
|
||||
|
||||
export interface IpcDeps {
|
||||
connection: DatabaseConnection;
|
||||
@@ -34,6 +35,7 @@ export function registerIpcHandlers(deps: IpcDeps): void {
|
||||
const migrations = new MigrationService(deps.connection.db);
|
||||
const compliance = new ComplianceService(deps.connection.db);
|
||||
const search = new SearchService(deps.connection.db);
|
||||
const identityAccess = new IdentityAccessService(deps.connection.db);
|
||||
const onboarding = new OnboardingService({
|
||||
db: deps.connection.db,
|
||||
saveProviderCredential: (input) => providers.saveCredential(input),
|
||||
@@ -78,6 +80,8 @@ export function registerIpcHandlers(deps: IpcDeps): void {
|
||||
ipcMain.handle(IPC_CHANNELS.updateTaskStatus, (_event, input) => mutate(() => organization.updateTaskStatus(input.orgId, input.taskId, input.status)));
|
||||
ipcMain.handle(IPC_CHANNELS.createApproval, (_event, input) => mutate(() => approvals.create(input)));
|
||||
ipcMain.handle(IPC_CHANNELS.decideApproval, (_event, input) => mutate(() => approvals.decide(input)));
|
||||
ipcMain.handle(IPC_CHANNELS.syncEmployeeIdentity, (_event, input) => mutate(() => identityAccess.syncEmployeeIdentity(input)));
|
||||
ipcMain.handle(IPC_CHANNELS.grantRole, (_event, input) => mutate(() => identityAccess.grantRole(input)));
|
||||
ipcMain.handle(IPC_CHANNELS.createChatSpace, (_event, input) => mutate(() => chat.createSpace(input)));
|
||||
ipcMain.handle(IPC_CHANNELS.sendMessage, (_event, input) => mutate(() => chat.sendMessage(input)));
|
||||
ipcMain.handle(IPC_CHANNELS.markChatSpaceRead, (_event, input) => mutate(() => chat.markSpaceRead(input)));
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
export const identityAccessSchema = `
|
||||
create table if not exists employee_identities (
|
||||
id text primary key,
|
||||
org_id text not null references organizations(id) on delete cascade,
|
||||
employee_id text not null references employees(id) on delete cascade,
|
||||
source text not null,
|
||||
external_subject text not null,
|
||||
email text,
|
||||
active integer not null,
|
||||
created_at text not null,
|
||||
last_seen_at text,
|
||||
unique(org_id, source, external_subject)
|
||||
);
|
||||
|
||||
create table if not exists service_identities (
|
||||
id text primary key,
|
||||
org_id text not null references organizations(id) on delete cascade,
|
||||
agent_id text not null references agents(id) on delete cascade,
|
||||
name text not null,
|
||||
scope_json text not null,
|
||||
created_at text not null,
|
||||
unique(org_id, agent_id)
|
||||
);
|
||||
|
||||
create table if not exists role_grants (
|
||||
id text primary key,
|
||||
org_id text not null references organizations(id) on delete cascade,
|
||||
principal_type text not null,
|
||||
principal_id text not null,
|
||||
role_key text not null,
|
||||
scope_type text not null,
|
||||
scope_id text not null,
|
||||
granted_by_employee_id text references employees(id) on delete set null,
|
||||
created_at text not null,
|
||||
unique(org_id, principal_type, principal_id, role_key, scope_type, scope_id)
|
||||
);
|
||||
|
||||
create table if not exists access_decisions (
|
||||
id text primary key,
|
||||
org_id text not null references organizations(id) on delete cascade,
|
||||
principal_type text not null,
|
||||
principal_id text not null,
|
||||
action text not null,
|
||||
resource_type text not null,
|
||||
resource_id text not null,
|
||||
decision text not null,
|
||||
reason text not null,
|
||||
created_at text not null
|
||||
);
|
||||
|
||||
create index if not exists idx_role_grants_principal on role_grants(org_id, principal_type, principal_id);
|
||||
create index if not exists idx_access_decisions_org_created on access_decisions(org_id, created_at);
|
||||
|
||||
insert into employee_identities(id, org_id, employee_id, source, external_subject, email, active, created_at, last_seen_at)
|
||||
select 'eid_' || lower(hex(randomblob(16))), org_id, id, 'bootstrap', id, null, 1, datetime('now'), datetime('now')
|
||||
from employees
|
||||
where not exists (
|
||||
select 1 from employee_identities
|
||||
where employee_identities.org_id = employees.org_id and employee_identities.employee_id = employees.id
|
||||
);
|
||||
|
||||
insert into role_grants(id, org_id, principal_type, principal_id, role_key, scope_type, scope_id, granted_by_employee_id, created_at)
|
||||
select 'rgr_' || lower(hex(randomblob(16))), org_id, 'employee', id, seed_role_key, 'organization', org_id, null, datetime('now')
|
||||
from employees
|
||||
cross join (
|
||||
select 'org_admin' as seed_role_key
|
||||
union all select 'approval_decider'
|
||||
union all select 'hiring_manager'
|
||||
)
|
||||
where not exists (
|
||||
select 1 from role_grants
|
||||
where role_grants.org_id = employees.org_id
|
||||
and role_grants.principal_type = 'employee'
|
||||
and role_grants.principal_id = employees.id
|
||||
and role_grants.role_key = seed_role_key
|
||||
and role_grants.scope_type = 'organization'
|
||||
and role_grants.scope_id = employees.org_id
|
||||
);
|
||||
|
||||
insert into service_identities(id, org_id, agent_id, name, scope_json, created_at)
|
||||
select 'sid_' || lower(hex(randomblob(16))), org_id, id, name || ' service identity', json_object('agentId', id), datetime('now')
|
||||
from agents
|
||||
where not exists (
|
||||
select 1 from service_identities
|
||||
where service_identities.org_id = agents.org_id and service_identities.agent_id = agents.id
|
||||
);
|
||||
|
||||
insert into schema_migrations(version, name, applied_at)
|
||||
values (15, 'identity_access', datetime('now'));
|
||||
`;
|
||||
@@ -13,6 +13,7 @@ import { boardInterventionSchema } from "./migrations/011-board-interventions";
|
||||
import { complianceControlsSchema } from "./migrations/012-compliance-controls";
|
||||
import { searchDocumentsSchema } from "./migrations/013-search-documents";
|
||||
import { providerPolicyUsageSchema } from "./migrations/014-provider-policy-usage";
|
||||
import { identityAccessSchema } from "./migrations/015-identity-access";
|
||||
|
||||
interface Migration {
|
||||
version: number;
|
||||
@@ -35,6 +36,7 @@ const MIGRATIONS: Migration[] = [
|
||||
{ version: 12, name: "compliance_controls", sql: complianceControlsSchema },
|
||||
{ version: 13, name: "search_documents", sql: searchDocumentsSchema },
|
||||
{ version: 14, name: "provider_policy_usage", sql: providerPolicyUsageSchema },
|
||||
{ version: 15, name: "identity_access", sql: identityAccessSchema },
|
||||
];
|
||||
|
||||
const SCHEMA_VERSION = MIGRATIONS[MIGRATIONS.length - 1].version;
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
import type { CreateApprovalInput, DecideApprovalInput } from "../../shared/commands";
|
||||
import { APPROVAL_STATE } from "../../shared/domain";
|
||||
import { APPROVAL_STATE, ROLE_KEY } from "../../shared/domain";
|
||||
import { createId } from "../../shared/ids";
|
||||
import { AuditService } from "./audit-service";
|
||||
import { IdentityAccessService } from "./identity-access-service";
|
||||
|
||||
interface ApprovalAuditContext {
|
||||
sourceMessageId?: string;
|
||||
@@ -11,9 +12,11 @@ interface ApprovalAuditContext {
|
||||
|
||||
export class ApprovalService {
|
||||
private readonly audit: AuditService;
|
||||
private readonly identityAccess: IdentityAccessService;
|
||||
|
||||
constructor(private readonly db: DatabaseSync) {
|
||||
this.audit = new AuditService(db);
|
||||
this.identityAccess = new IdentityAccessService(db);
|
||||
}
|
||||
|
||||
create(input: CreateApprovalInput, context: ApprovalAuditContext = {}): string {
|
||||
@@ -58,7 +61,15 @@ export class ApprovalService {
|
||||
}
|
||||
|
||||
decide(input: DecideApprovalInput): void {
|
||||
this.assertEmployee(input.orgId, input.decidedByEmployeeId, "Deciding employee was not found.");
|
||||
this.identityAccess.assertEmployeeRole(
|
||||
input.orgId,
|
||||
input.decidedByEmployeeId,
|
||||
ROLE_KEY.APPROVAL_DECIDER,
|
||||
"approval_decide",
|
||||
"approval",
|
||||
input.approvalId,
|
||||
"Deciding employee was not found.",
|
||||
);
|
||||
const now = new Date().toISOString();
|
||||
const result = this.db.prepare(`
|
||||
update approvals
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
import type { GrantRoleInput, SyncEmployeeIdentityInput } from "../../shared/commands";
|
||||
import { ACCESS_DECISION, IDENTITY_SOURCE, ROLE_KEY, type PrincipalType, type RoleKey } from "../../shared/domain";
|
||||
import { createId } from "../../shared/ids";
|
||||
import { AuditService } from "./audit-service";
|
||||
|
||||
const ROLE_KEYS = new Set(Object.values(ROLE_KEY));
|
||||
const IDENTITY_SOURCES = new Set(Object.values(IDENTITY_SOURCE));
|
||||
const SCOPE_TYPES = new Set(["organization", "workspace", "project", "department"]);
|
||||
|
||||
export class IdentityAccessService {
|
||||
private readonly audit: AuditService;
|
||||
|
||||
constructor(private readonly db: DatabaseSync) {
|
||||
this.audit = new AuditService(db);
|
||||
}
|
||||
|
||||
seedEmployeeAccess(orgId: string, employeeId: string, now: string): void {
|
||||
this.db.prepare(`
|
||||
insert into employee_identities(id, org_id, employee_id, source, external_subject, email, active, created_at, last_seen_at)
|
||||
values (?, ?, ?, ?, ?, null, 1, ?, ?)
|
||||
on conflict(org_id, source, external_subject) do nothing
|
||||
`).run(createId("eid"), orgId, employeeId, IDENTITY_SOURCE.BOOTSTRAP, employeeId, now, now);
|
||||
for (const roleKey of [ROLE_KEY.ORG_ADMIN, ROLE_KEY.APPROVAL_DECIDER, ROLE_KEY.HIRING_MANAGER]) {
|
||||
this.insertRoleGrant({
|
||||
orgId,
|
||||
principalType: "employee",
|
||||
principalId: employeeId,
|
||||
roleKey,
|
||||
scopeType: "organization",
|
||||
scopeId: orgId,
|
||||
grantedByEmployeeId: null,
|
||||
now,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
createAgentServiceIdentity(orgId: string, agentId: string, name: string, now: string): void {
|
||||
const serviceIdentityId = createId("sid");
|
||||
this.db.prepare(`
|
||||
insert into service_identities(id, org_id, agent_id, name, scope_json, created_at)
|
||||
values (?, ?, ?, ?, ?, ?)
|
||||
on conflict(org_id, agent_id) do nothing
|
||||
`).run(serviceIdentityId, orgId, agentId, `${name.trim()} service identity`, JSON.stringify({ agentId }), now);
|
||||
const existing = this.db.prepare("select id from service_identities where org_id = ? and agent_id = ?")
|
||||
.get(orgId, agentId) as { id: string } | undefined;
|
||||
if (!existing) throw new Error("Agent service identity was not created.");
|
||||
this.insertRoleGrant({
|
||||
orgId,
|
||||
principalType: "service_identity",
|
||||
principalId: existing.id,
|
||||
roleKey: ROLE_KEY.SCOPED_AGENT,
|
||||
scopeType: "organization",
|
||||
scopeId: orgId,
|
||||
grantedByEmployeeId: null,
|
||||
now,
|
||||
});
|
||||
}
|
||||
|
||||
syncEmployeeIdentity(input: SyncEmployeeIdentityInput): void {
|
||||
this.assertEmployee(input.orgId, input.employeeId, "Employee was not found.");
|
||||
if (!IDENTITY_SOURCES.has(input.source)) throw new Error("Identity source is invalid.");
|
||||
const externalSubject = input.externalSubject.trim();
|
||||
if (!externalSubject) throw new Error("External subject is required.");
|
||||
const email = input.email?.trim() || null;
|
||||
const now = new Date().toISOString();
|
||||
this.db.prepare(`
|
||||
insert into employee_identities(id, org_id, employee_id, source, external_subject, email, active, created_at, last_seen_at)
|
||||
values (?, ?, ?, ?, ?, ?, 1, ?, ?)
|
||||
on conflict(org_id, source, external_subject) do update set
|
||||
employee_id = excluded.employee_id,
|
||||
email = excluded.email,
|
||||
active = 1,
|
||||
last_seen_at = excluded.last_seen_at
|
||||
`).run(createId("eid"), input.orgId, input.employeeId, input.source, externalSubject, email, now, now);
|
||||
this.audit.record({
|
||||
orgId: input.orgId,
|
||||
entityType: "employee_identity",
|
||||
entityId: input.employeeId,
|
||||
actorType: "system",
|
||||
actorId: null,
|
||||
action: "synced",
|
||||
payload: { source: input.source, externalSubject, email },
|
||||
});
|
||||
}
|
||||
|
||||
grantRole(input: GrantRoleInput): void {
|
||||
this.assertEmployee(input.orgId, input.grantedByEmployeeId, "Granting employee was not found.");
|
||||
this.assertEmployeeRole(input.orgId, input.grantedByEmployeeId, ROLE_KEY.ORG_ADMIN, "role_grant", input.scopeType, input.scopeId);
|
||||
if (!ROLE_KEYS.has(input.roleKey)) throw new Error("Role key is invalid.");
|
||||
if (!SCOPE_TYPES.has(input.scopeType)) throw new Error("Role scope type is invalid.");
|
||||
this.assertPrincipal(input.orgId, input.principalType, input.principalId);
|
||||
const now = new Date().toISOString();
|
||||
const changes = this.insertRoleGrant({ ...input, now });
|
||||
if (changes > 0) {
|
||||
this.audit.record({
|
||||
orgId: input.orgId,
|
||||
entityType: "role_grant",
|
||||
entityId: input.principalId,
|
||||
actorType: "employee",
|
||||
actorId: input.grantedByEmployeeId,
|
||||
action: "granted",
|
||||
payload: { principalType: input.principalType, roleKey: input.roleKey, scopeType: input.scopeType, scopeId: input.scopeId },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
assertEmployeeRole(
|
||||
orgId: string,
|
||||
employeeId: string,
|
||||
roleKey: RoleKey,
|
||||
action: string,
|
||||
resourceType: string,
|
||||
resourceId: string,
|
||||
employeeMissingMessage = "Employee was not found.",
|
||||
): void {
|
||||
this.assertEmployee(orgId, employeeId, employeeMissingMessage);
|
||||
const allowed = this.hasRole(orgId, "employee", employeeId, roleKey) || this.hasRole(orgId, "employee", employeeId, ROLE_KEY.ORG_ADMIN);
|
||||
this.recordDecision(orgId, "employee", employeeId, action, resourceType, resourceId, allowed, allowed ? roleKey : "missing_role");
|
||||
if (!allowed) throw new Error(`Employee requires ${roleKey} role.`);
|
||||
}
|
||||
|
||||
private hasRole(orgId: string, principalType: PrincipalType, principalId: string, roleKey: RoleKey): boolean {
|
||||
const row = this.db.prepare(`
|
||||
select id from role_grants
|
||||
where org_id = ? and principal_type = ? and principal_id = ? and role_key = ?
|
||||
limit 1
|
||||
`).get(orgId, principalType, principalId, roleKey);
|
||||
return Boolean(row);
|
||||
}
|
||||
|
||||
private insertRoleGrant(input: {
|
||||
orgId: string; principalType: PrincipalType; principalId: string; roleKey: RoleKey;
|
||||
scopeType: string; scopeId: string; grantedByEmployeeId: string | null; now: string;
|
||||
}): number {
|
||||
const result = this.db.prepare(`
|
||||
insert into role_grants(id, org_id, principal_type, principal_id, role_key, scope_type, scope_id, granted_by_employee_id, created_at)
|
||||
values (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
on conflict(org_id, principal_type, principal_id, role_key, scope_type, scope_id) do nothing
|
||||
`).run(createId("rgr"), input.orgId, input.principalType, input.principalId, input.roleKey, input.scopeType, input.scopeId, input.grantedByEmployeeId, input.now);
|
||||
return Number(result.changes);
|
||||
}
|
||||
|
||||
private assertPrincipal(orgId: string, principalType: PrincipalType, principalId: string): void {
|
||||
if (principalType === "employee") this.assertEmployee(orgId, principalId, "Employee was not found.");
|
||||
else if (principalType === "agent") this.assertAgent(orgId, principalId);
|
||||
else if (principalType === "service_identity") this.assertServiceIdentity(orgId, principalId);
|
||||
else throw new Error("Principal type is invalid.");
|
||||
}
|
||||
|
||||
private assertEmployee(orgId: string, employeeId: string, message: string): void {
|
||||
const row = this.db.prepare("select id from employees where id = ? and org_id = ?").get(employeeId, orgId);
|
||||
if (!row) throw new Error(message);
|
||||
}
|
||||
|
||||
private assertAgent(orgId: string, agentId: string): void {
|
||||
const row = this.db.prepare("select id from agents where id = ? and org_id = ?").get(agentId, orgId);
|
||||
if (!row) throw new Error("Agent was not found.");
|
||||
}
|
||||
|
||||
private assertServiceIdentity(orgId: string, serviceIdentityId: string): void {
|
||||
const row = this.db.prepare("select id from service_identities where id = ? and org_id = ?").get(serviceIdentityId, orgId);
|
||||
if (!row) throw new Error("Service identity was not found.");
|
||||
}
|
||||
|
||||
private recordDecision(
|
||||
orgId: string,
|
||||
principalType: PrincipalType,
|
||||
principalId: string,
|
||||
action: string,
|
||||
resourceType: string,
|
||||
resourceId: string,
|
||||
allowed: boolean,
|
||||
reason: string,
|
||||
): void {
|
||||
this.db.prepare(`
|
||||
insert into access_decisions(id, org_id, principal_type, principal_id, action, resource_type, resource_id, decision, reason, created_at)
|
||||
values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(createId("acd"), orgId, principalType, principalId, action, resourceType, resourceId, allowed ? ACCESS_DECISION.ALLOW : ACCESS_DECISION.DENY, reason, new Date().toISOString());
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,10 @@ import { AuditService } from "./audit-service";
|
||||
const LEGACY_TABLES = [
|
||||
"organizations",
|
||||
"employees",
|
||||
"employee_identities",
|
||||
"service_identities",
|
||||
"role_grants",
|
||||
"access_decisions",
|
||||
"agents",
|
||||
"tasks",
|
||||
"workspaces",
|
||||
@@ -85,6 +89,13 @@ interface MigrationReportBody {
|
||||
|
||||
const RELATION_CHECKS: RelationCheck[] = [
|
||||
{ table: "employees", column: "org_id", referencedTable: "organizations", referencedColumn: "id", reason: "dangling_org_id" },
|
||||
{ table: "employee_identities", column: "org_id", referencedTable: "organizations", referencedColumn: "id", reason: "dangling_org_id" },
|
||||
{ table: "employee_identities", column: "employee_id", referencedTable: "employees", referencedColumn: "id", reason: "dangling_employee_id" },
|
||||
{ table: "service_identities", column: "org_id", referencedTable: "organizations", referencedColumn: "id", reason: "dangling_org_id" },
|
||||
{ table: "service_identities", column: "agent_id", referencedTable: "agents", referencedColumn: "id", reason: "dangling_agent_id" },
|
||||
{ table: "role_grants", column: "org_id", referencedTable: "organizations", referencedColumn: "id", reason: "dangling_org_id" },
|
||||
{ table: "role_grants", column: "granted_by_employee_id", referencedTable: "employees", referencedColumn: "id", reason: "dangling_grantor_employee_id" },
|
||||
{ table: "access_decisions", column: "org_id", referencedTable: "organizations", referencedColumn: "id", reason: "dangling_org_id" },
|
||||
{ table: "agents", column: "org_id", referencedTable: "organizations", referencedColumn: "id", reason: "dangling_org_id" },
|
||||
{ table: "agents", column: "model_provider_id", referencedTable: "providers", referencedColumn: "id", reason: "dangling_model_provider_id" },
|
||||
{ table: "agents", column: "manager_agent_id", referencedTable: "agents", referencedColumn: "id", reason: "dangling_manager_agent_id" },
|
||||
|
||||
@@ -5,15 +5,18 @@ import { createId } from "../../shared/ids";
|
||||
import type { CreateAgentInput, CreateOrganizationInput, CreateTaskInput } from "../../shared/commands";
|
||||
import { AuditService } from "./audit-service";
|
||||
import { PolicyService } from "./policy-service";
|
||||
import { IdentityAccessService } from "./identity-access-service";
|
||||
import { assertUsableMentionName } from "./mention-handles";
|
||||
|
||||
export class OrganizationService {
|
||||
private readonly audit: AuditService;
|
||||
private readonly policy: PolicyService;
|
||||
private readonly identityAccess: IdentityAccessService;
|
||||
|
||||
constructor(private readonly db: DatabaseSync) {
|
||||
this.audit = new AuditService(db);
|
||||
this.policy = new PolicyService(db);
|
||||
this.identityAccess = new IdentityAccessService(db);
|
||||
}
|
||||
|
||||
createOrganization(input: CreateOrganizationInput): string {
|
||||
@@ -40,6 +43,7 @@ export class OrganizationService {
|
||||
);
|
||||
this.db.prepare("insert into employees(id, org_id, display_name, role, created_at) values (?, ?, ?, ?, ?)")
|
||||
.run(employeeId, orgId, input.employeeName.trim(), "Board Chair", now);
|
||||
this.identityAccess.seedEmployeeAccess(orgId, employeeId, now);
|
||||
|
||||
for (const space of [
|
||||
["company", "general", "Company coordination", "workspace"],
|
||||
@@ -167,6 +171,7 @@ export class OrganizationService {
|
||||
payload: { role: input.role, department: input.department },
|
||||
});
|
||||
this.addAgentToWorkspaceSpaces(input.orgId, id);
|
||||
this.identityAccess.createAgentServiceIdentity(input.orgId, id, input.name, now);
|
||||
return id;
|
||||
}
|
||||
|
||||
|
||||
@@ -51,6 +51,8 @@ export class SnapshotService {
|
||||
.map((hold) => ({ ...hold, active: toBool(hold.active) }));
|
||||
const retentionFreezes = list<AppSnapshot["retentionFreezes"][number]>(this.db, "retention_freezes", "created_at desc")
|
||||
.map((freeze) => ({ ...freeze, active: toBool(freeze.active) }));
|
||||
const employeeIdentities = list<AppSnapshot["employeeIdentities"][number]>(this.db, "employee_identities", "created_at desc")
|
||||
.map((identity) => ({ ...identity, active: toBool(identity.active) }));
|
||||
|
||||
return {
|
||||
activeOrgId,
|
||||
@@ -90,6 +92,10 @@ export class SnapshotService {
|
||||
complianceExports: list(this.db, "compliance_exports", "created_at desc"),
|
||||
legalHolds,
|
||||
retentionFreezes,
|
||||
employeeIdentities,
|
||||
serviceIdentities: list(this.db, "service_identities", "created_at desc"),
|
||||
roleGrants: list(this.db, "role_grants", "created_at desc"),
|
||||
accessDecisions: list<AppSnapshot["accessDecisions"][number]>(this.db, "access_decisions", "created_at desc").slice(0, 200),
|
||||
auditEvents: list<AppSnapshot["auditEvents"][number]>(this.db, "audit_events", "created_at desc").slice(0, 200),
|
||||
};
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user