Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 21 additions & 4 deletions apps/api/src/common/workspace-context.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,8 +138,16 @@ export class WorkspaceContextService {
}

async getDefaultWorkspaceId(userId: string, options?: { autoBootstrap?: boolean }): Promise<string> {
const membership = await this.getDefaultMembership(userId, options);
return membership.workspaceId;
}

async getDefaultMembership(
userId: string,
options?: { autoBootstrap?: boolean }
): Promise<{ workspaceId: string; role: WorkspaceRole }> {
const autoBootstrap = options?.autoBootstrap ?? true;
const member = await this.resolveMembership(userId);
let member = await this.resolveMembership(userId);
if (!member) {
if (!autoBootstrap) {
throw new NotFoundException({
Expand All @@ -151,8 +159,14 @@ export class WorkspaceContextService {
});
}

const bootstrapped = await this.bootstrapDefaultWorkspace(userId);
return bootstrapped.workspaceId;
await this.bootstrapDefaultWorkspace(userId);
member = await this.resolveMembership(userId);
if (!member) {
throw new NotFoundException({
code: 'WORKSPACE_NOT_FOUND',
message: '默认工作区初始化后仍未找到成员关系'
});
}
}

if (!member.isDefault) {
Expand All @@ -162,6 +176,9 @@ export class WorkspaceContextService {
});
}

return member.workspaceId;
return {
workspaceId: member.workspaceId,
role: member.role
};
}
}
121 changes: 121 additions & 0 deletions apps/api/src/modules/audit/audit-visibility.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import type {
AuditLogEntity,
AuditPayloadAccess,
AuditVisibility,
AuditVisibilityDomain,
WorkspaceRoleValue
} from '@draftorbit/shared';

const AUDIT_RESOURCE_DOMAIN: Record<string, AuditVisibilityDomain> = {
draft: 'CONTENT',
topic: 'CONTENT',
playbook: 'CONTENT',
voice_profile: 'CONTENT',
learning_source: 'LEARNING',
media_asset: 'MEDIA',
publish_job: 'PUBLISHING',
reply_job: 'REPLY',
reply_candidate: 'REPLY',
workflow_template: 'WORKFLOW',
workflow_run: 'WORKFLOW',
provider_connection: 'INTEGRATIONS',
x_account: 'INTEGRATIONS',
billing_account: 'BILLING',
workspace: 'WORKSPACE_ADMIN'
};

const ALL_AUDIT_DOMAINS: AuditVisibilityDomain[] = [
'CONTENT',
'LEARNING',
'MEDIA',
'PUBLISHING',
'REPLY',
'WORKFLOW',
'INTEGRATIONS',
'BILLING',
'WORKSPACE_ADMIN',
'UNKNOWN'
];

const EDITOR_VISIBLE_DOMAINS: AuditVisibilityDomain[] = [
'CONTENT',
'LEARNING',
'MEDIA',
'PUBLISHING',
'REPLY',
'WORKFLOW'
];

function sortDomains(domains: AuditVisibilityDomain[]) {
return [...domains].sort((a, b) => ALL_AUDIT_DOMAINS.indexOf(a) - ALL_AUDIT_DOMAINS.indexOf(b));
}

function payloadToRecord(payload: unknown): Record<string, unknown> | null {
if (payload == null) return null;
if (payload && typeof payload === 'object' && !Array.isArray(payload)) {
return payload as Record<string, unknown>;
}
return { value: payload };
}

export function classifyAuditDomain(resourceType: string): AuditVisibilityDomain {
return AUDIT_RESOURCE_DOMAIN[resourceType] ?? 'UNKNOWN';
}

export function buildAuditVisibility(role: WorkspaceRoleValue): AuditVisibility {
if (role === 'OWNER' || role === 'ADMIN') {
return {
role,
scope: 'FULL_WORKSPACE',
payloadAccess: 'FULL',
visibleDomains: [...ALL_AUDIT_DOMAINS],
hiddenDomains: []
};
}

const visibleDomains = sortDomains(EDITOR_VISIBLE_DOMAINS);
const hiddenDomains = sortDomains(ALL_AUDIT_DOMAINS.filter((domain) => !visibleDomains.includes(domain)));

return {
role,
scope: 'OPERATIONS_ONLY',
payloadAccess: role === 'EDITOR' ? 'FULL' : 'NONE',
visibleDomains,
hiddenDomains
};
}

export function getVisibleAuditResourceTypes(role: WorkspaceRoleValue): string[] | null {
const visibility = buildAuditVisibility(role);
if (visibility.scope === 'FULL_WORKSPACE') {
return null;
}

return Object.entries(AUDIT_RESOURCE_DOMAIN)
.filter(([, domain]) => visibility.visibleDomains.includes(domain))
.map(([resourceType]) => resourceType)
.sort();
}

export function sanitizeAuditLog(
log: {
id: string;
action: string;
resourceType: string;
resourceId: string | null;
payload: unknown;
createdAt: Date | string;
},
payloadAccess: AuditPayloadAccess
): AuditLogEntity {
return {
id: log.id,
action: log.action,
resourceType: log.resourceType,
resourceId: log.resourceId ?? null,
payload: payloadAccess === 'FULL' ? payloadToRecord(log.payload) : null,
createdAt: log.createdAt instanceof Date ? log.createdAt.toISOString() : log.createdAt,
visibilityDomain: classifyAuditDomain(log.resourceType),
payloadRedacted: payloadAccess !== 'FULL' && log.payload != null
};
}
102 changes: 81 additions & 21 deletions apps/api/src/modules/audit/audit.service.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { Inject, Injectable } from '@nestjs/common';
import { AuditActionType } from '@draftorbit/db';
import type { AuditLogsResponse, AuditSummaryEntity } from '@draftorbit/shared';
import { PrismaService } from '../../common/prisma.service';
import { WorkspaceContextService } from '../../common/workspace-context.service';
import { buildAuditVisibility, getVisibleAuditResourceTypes, sanitizeAuditLog } from './audit-visibility';

@Injectable()
export class AuditService {
Expand All @@ -17,39 +19,97 @@ export class AuditService {
resourceType?: string;
limit?: number;
}
) {
const workspaceId = await this.workspaceContext.getDefaultWorkspaceId(userId);

return this.prisma.db.auditLog.findMany({
where: {
workspaceId,
...(filters.action ? { action: filters.action } : {}),
...(filters.resourceType ? { resourceType: filters.resourceType } : {})
},
orderBy: { createdAt: 'desc' },
take: Math.min(Math.max(filters.limit ?? 100, 1), 500)
});
): Promise<AuditLogsResponse> {
const membership = await this.workspaceContext.getDefaultMembership(userId);
const visibility = buildAuditVisibility(membership.role);
const visibleResourceTypes = getVisibleAuditResourceTypes(membership.role);
const safeLimit = Math.min(Math.max(filters.limit ?? 100, 1), 500);

const baseWhere = {
workspaceId: membership.workspaceId,
...(filters.action ? { action: filters.action } : {})
};

const visibleResourceTypeFilter = filters.resourceType
? visibleResourceTypes && !visibleResourceTypes.includes(filters.resourceType)
? { in: [] as string[] }
: filters.resourceType
: visibleResourceTypes
? { in: visibleResourceTypes }
: undefined;

const visibleWhere = {
...baseWhere,
...(visibleResourceTypeFilter ? { resourceType: visibleResourceTypeFilter } : {})
};

const [workspaceMatchingCount, visibleMatchingCount, logs] = await Promise.all([
this.prisma.db.auditLog.count({
where: {
...baseWhere,
...(filters.resourceType ? { resourceType: filters.resourceType } : {})
}
}),
this.prisma.db.auditLog.count({ where: visibleWhere }),
this.prisma.db.auditLog.findMany({
where: visibleWhere,
orderBy: { createdAt: 'desc' },
take: safeLimit
})
]);

return {
items: logs.map((log) => sanitizeAuditLog(log, visibility.payloadAccess)),
hiddenCount: Math.max(workspaceMatchingCount - visibleMatchingCount, 0),
visibility,
limit: safeLimit
};
}

async summary(userId: string) {
const workspaceId = await this.workspaceContext.getDefaultWorkspaceId(userId);
async summary(userId: string): Promise<AuditSummaryEntity> {
const membership = await this.workspaceContext.getDefaultMembership(userId);
const visibility = buildAuditVisibility(membership.role);
const visibleResourceTypes = getVisibleAuditResourceTypes(membership.role);
const last24h = new Date(Date.now() - 24 * 60 * 60 * 1000);

const [total, last24h] = await Promise.all([
this.prisma.db.auditLog.count({ where: { workspaceId } }),
const visibleFilter = visibleResourceTypes ? { resourceType: { in: visibleResourceTypes } } : {};

const [workspaceTotal, workspaceLast24h, visibleTotal, visibleLast24h] = await Promise.all([
this.prisma.db.auditLog.count({ where: { workspaceId: membership.workspaceId } }),
this.prisma.db.auditLog.count({
where: {
workspaceId,
workspaceId: membership.workspaceId,
createdAt: {
gte: new Date(Date.now() - 24 * 60 * 60 * 1000)
gte: last24h
}
}
}),
this.prisma.db.auditLog.count({
where: {
workspaceId: membership.workspaceId,
...visibleFilter
}
}),
this.prisma.db.auditLog.count({
where: {
workspaceId: membership.workspaceId,
createdAt: {
gte: last24h
},
...visibleFilter
}
})
]);

return {
workspaceId,
total,
last24h
workspaceId: membership.workspaceId,
total: visibleTotal,
last24h: visibleLast24h,
workspaceTotal,
workspaceLast24h,
hiddenTotal: Math.max(workspaceTotal - visibleTotal, 0),
hiddenLast24h: Math.max(workspaceLast24h - visibleLast24h, 0),
visibility
};
}
}
Loading