feat: add account roles audit and task authorization

This commit is contained in:
inman committed 2026-09-01 19:14:06 +08:00
1 parent 337aaf7c88
commit 191c1a1aad
15 files changed
+4961 -457

No files matched your search

+465 -19
View File
@@ -2,17 +2,38 @@ import argon2 from 'argon2';
import type { AppConfig } from './config.js';
import { getPool, withTransaction } from './db.js';
import { hashToken, randomToken, sameTokenHash } from './crypto.js';
import {
BUSINESS_ROUTES,
businessRouteById,
type BusinessRouteId
} from './business-routes.js';
import {
diagnosticMetadataKeys,
noopDiagnosticLogger,
type DiagnosticLogger
} from './diagnostics.js';
export type AuthRole = 'admin' | 'team_lead' | 'user';
export interface AuthUser {
id: string;
organizationId: string;
username: string;
role: 'admin';
role: AuthRole;
mustChangePassword: boolean;
}
export interface PublicAccount {
id: string;
username: string;
role: AuthRole;
is_active: boolean;
must_change_password: boolean;
authorized_business_route_ids: BusinessRouteId[];
business_authorization_revision: number;
last_login_at: string | null;
created_at: string;
updated_at: string;
}
export interface AuthSession {
@@ -43,15 +64,99 @@ function normalizeUsername(value: string): string {
return String(value || '').trim().toLowerCase();
}
function validateUsername(value: string): string {
const normalized = normalizeUsername(value);
if (!normalized || normalized.length > 160) {
throw new AuthError('username_invalid', '账号必须为 1—160 个字符。', 400);
}
return normalized;
}
function validatePassword(value: string): string {
const password = String(value || '');
if (password.length < 12 || password.length > 512) {
throw new AuthError('password_invalid', '密码必须为 12—512 个字符。', 400);
}
return password;
}
function normalizeRole(value: unknown): AuthRole {
if (value === 'admin' || value === 'team_lead') return value;
return 'user';
}
function isoOrNull(value: unknown): string | null {
if (!value) return null;
const date = new Date(String(value));
return Number.isFinite(date.getTime()) ? date.toISOString() : null;
}
const ALL_BUSINESS_ROUTE_IDS = BUSINESS_ROUTES.map((route) => route.routeId);
const BUSINESS_ROUTE_DISPLAY_ORDER = new Map(
ALL_BUSINESS_ROUTE_IDS.map((routeId, index) => [routeId, index])
);
function normalizeBusinessRouteIds(values: readonly unknown[] | undefined): BusinessRouteId[] {
const normalized = [...new Set((values || []).map((value) => String(value || '').trim()).filter(Boolean))];
const invalid = normalized.find((routeId) => !businessRouteById(routeId));
if (invalid) throw new AuthError('business_route_invalid', `业务类型 ${invalid} 不存在。`, 400);
return (normalized as BusinessRouteId[]).sort((left, right) => (
(BUSINESS_ROUTE_DISPLAY_ORDER.get(left) ?? Number.MAX_SAFE_INTEGER)
- (BUSINESS_ROUTE_DISPLAY_ORDER.get(right) ?? Number.MAX_SAFE_INTEGER)
));
}
function mapUser(row: Record<string, unknown>): AuthUser {
return {
id: String(row.id),
organizationId: String(row.organization_id),
username: String(row.username),
role: 'admin'
role: normalizeRole(row.role),
mustChangePassword: row.must_change_password === true || String(row.must_change_password) === 'true'
};
}
function mapAccount(row: Record<string, unknown>): PublicAccount {
const role = normalizeRole(row.role);
const storedRouteIds = normalizeBusinessRouteIds(
Array.isArray(row.authorized_business_route_ids) ? row.authorized_business_route_ids : []
);
return {
id: String(row.id),
username: String(row.username),
role,
is_active: row.is_active === true || String(row.is_active) === 'true',
must_change_password: row.must_change_password === true || String(row.must_change_password) === 'true',
authorized_business_route_ids: role === 'admin' ? [...ALL_BUSINESS_ROUTE_IDS] : storedRouteIds,
business_authorization_revision: Math.max(0, Number(row.business_authorization_revision || 0)),
last_login_at: isoOrNull(row.last_login_at),
created_at: isoOrNull(row.created_at) || new Date(0).toISOString(),
updated_at: isoOrNull(row.updated_at) || new Date(0).toISOString()
};
}
async function loadPublicAccount(
client: import('pg').PoolClient,
organizationId: string,
userId: string
): Promise<PublicAccount | null> {
const result = await client.query(
`SELECT u.id, u.username, u.role, u.is_active, u.must_change_password,
u.business_authorization_revision,
u.last_login_at, u.created_at, u.updated_at,
COALESCE(ARRAY(
SELECT route_grant.route_id
FROM user_business_route_authorizations route_grant
WHERE route_grant.organization_id = u.organization_id
AND route_grant.user_id = u.id
), ARRAY[]::text[]) AS authorized_business_route_ids
FROM users u
WHERE u.organization_id = $1 AND u.id = $2`,
[organizationId, userId]
);
return result.rowCount ? mapAccount(result.rows[0] as Record<string, unknown>) : null;
}
export class AuthService {
private readonly dummyHashPromise = argon2.hash('ltjt-dummy-password', {
type: argon2.argon2id,
@@ -93,11 +198,10 @@ export class AuthService {
}
async bootstrapAdmin(username: string, password: string, { force = false } = {}): Promise<AuthUser> {
const normalized = normalizeUsername(username);
if (!normalized || normalized.length > 160) throw new Error('username must be 1-160 characters.');
if (!password || password.length < 12) throw new Error('password must be at least 12 characters.');
const normalized = validateUsername(username);
const validatedPassword = validatePassword(password);
const organization = await this.ensureOrganization();
const passwordHash = await argon2.hash(password, { type: argon2.argon2id });
const passwordHash = await argon2.hash(validatedPassword, { type: argon2.argon2id });
return withTransaction(this.config, async (client) => {
const existing = await client.query(
'SELECT id FROM users WHERE organization_id = $1 AND username = $2 FOR UPDATE',
@@ -109,16 +213,17 @@ export class AuthService {
const result = existing.rowCount
? await client.query(
`UPDATE users
SET password_hash = $1, is_active = true, failed_login_count = 0,
locked_until = NULL, updated_at = now()
SET password_hash = $1, role = 'admin', is_active = true,
must_change_password = false, password_changed_at = now(),
failed_login_count = 0, locked_until = NULL, updated_at = now()
WHERE id = $2
RETURNING id, organization_id, username`,
RETURNING id, organization_id, username, role, must_change_password`,
[passwordHash, existing.rows[0].id]
)
: await client.query(
`INSERT INTO users (organization_id, username, password_hash)
VALUES ($1, $2, $3)
RETURNING id, organization_id, username`,
`INSERT INTO users (organization_id, username, password_hash, role, must_change_password)
VALUES ($1, $2, $3, 'admin', false)
RETURNING id, organization_id, username, role, must_change_password`,
[organization.id, normalized, passwordHash]
);
const user = mapUser(result.rows[0]);
@@ -137,7 +242,7 @@ export class AuthService {
const pool = getPool(this.config);
const lookup = await pool.query(
`SELECT id, organization_id, username, password_hash, role, is_active,
failed_login_count, locked_until
must_change_password, failed_login_count, locked_until
FROM users
WHERE organization_id = (SELECT id FROM organizations WHERE slug = $1)
AND username = $2`,
@@ -179,12 +284,12 @@ export class AuthService {
}
async resetPassword(username: string, password: string): Promise<void> {
const normalized = normalizeUsername(username);
if (!password || password.length < 12) throw new Error('password must be at least 12 characters.');
const passwordHash = await argon2.hash(password, { type: argon2.argon2id });
const normalized = validateUsername(username);
const passwordHash = await argon2.hash(validatePassword(password), { type: argon2.argon2id });
const result = await getPool(this.config).query(
`UPDATE users
SET password_hash = $1, failed_login_count = 0, locked_until = NULL, updated_at = now()
SET password_hash = $1, must_change_password = false, password_changed_at = now(),
failed_login_count = 0, locked_until = NULL, updated_at = now()
WHERE organization_id = (SELECT id FROM organizations WHERE slug = $2)
AND username = $3
RETURNING id`,
@@ -197,6 +302,345 @@ export class AuthService {
);
}
private requireAdmin(actor: AuthUser): void {
if (actor.role !== 'admin') throw new AuthError('admin_required', '需要管理员权限。', 403);
}
private async accountAudit(
client: import('pg').PoolClient,
actor: AuthUser,
eventType: string,
targetUserId: string,
requestId: string,
metadata: Record<string, unknown> = {}
): Promise<void> {
await client.query(
`INSERT INTO audit_events
(organization_id, actor_user_id, event_type, entity_type, entity_id, request_id, metadata)
VALUES ($1, $2, $3, 'user', $4, $5, $6)`,
[actor.organizationId, actor.id, eventType, targetUserId, requestId, metadata]
);
this.log({
diagnostic_event: 'audit.event.staged',
diagnostic_stage: 'account_audit',
request_id: requestId,
domain_event: eventType,
entity_type: 'user',
actor_present: true,
metadata_keys: diagnosticMetadataKeys(metadata)
}, 'account audit event persisted');
}
async listAccounts(actor: AuthUser): Promise<PublicAccount[]> {
this.requireAdmin(actor);
const result = await getPool(this.config).query(
`SELECT u.id, u.username, u.role, u.is_active, u.must_change_password,
u.business_authorization_revision,
u.last_login_at, u.created_at, u.updated_at,
COALESCE(ARRAY(
SELECT route_grant.route_id
FROM user_business_route_authorizations route_grant
WHERE route_grant.organization_id = u.organization_id
AND route_grant.user_id = u.id
), ARRAY[]::text[]) AS authorized_business_route_ids
FROM users u
WHERE u.organization_id = $1
ORDER BY u.username ASC`,
[actor.organizationId]
);
return (result.rows as Record<string, unknown>[]).map(mapAccount);
}
async createAccount(
actor: AuthUser,
input: {
username: string;
password: string;
role: AuthRole;
mustChangePassword?: boolean;
businessRouteIds?: readonly string[];
},
requestId: string
): Promise<PublicAccount> {
this.requireAdmin(actor);
const username = validateUsername(input.username);
const passwordHash = await argon2.hash(validatePassword(input.password), { type: argon2.argon2id });
const role = normalizeRole(input.role);
const mustChangePassword = input.mustChangePassword !== false;
const businessRouteIds = role === 'admin' ? [] : normalizeBusinessRouteIds(input.businessRouteIds);
return withTransaction(this.config, async (client) => {
await client.query(
`SELECT pg_advisory_xact_lock(hashtextextended($1::text || ':' || $2::text, 0))`,
[actor.organizationId, username]
);
const existing = await client.query(
'SELECT id FROM users WHERE organization_id = $1 AND username = $2',
[actor.organizationId, username]
);
if (existing.rowCount) throw new AuthError('account_exists', '该账号已存在。', 409);
const created = await client.query(
`INSERT INTO users
(organization_id, username, password_hash, role, must_change_password, password_changed_at)
VALUES ($1, $2, $3, $4, $5, now())
RETURNING id`,
[actor.organizationId, username, passwordHash, role, mustChangePassword]
);
const accountId = String(created.rows[0].id);
if (businessRouteIds.length) {
await client.query(
`INSERT INTO user_business_route_authorizations
(organization_id, user_id, route_id, granted_by)
SELECT $1, $2, route_id, $3
FROM unnest($4::text[]) AS route_id`,
[actor.organizationId, accountId, actor.id, businessRouteIds]
);
}
const account = await loadPublicAccount(client, actor.organizationId, accountId);
if (!account) throw new AuthError('account_not_found', '账号创建后未能读取。', 500);
await this.accountAudit(client, actor, 'account.created', account.id, requestId, {
role,
must_change_password: mustChangePassword,
authorized_business_route_ids: account.authorized_business_route_ids
});
return account;
});
}
async updateAccount(
actor: AuthUser,
targetUserId: string,
input: { role?: AuthRole; isActive?: boolean },
requestId: string
): Promise<PublicAccount> {
this.requireAdmin(actor);
if (input.role === undefined && input.isActive === undefined) {
throw new AuthError('account_update_empty', '没有需要更新的账号字段。', 400);
}
return withTransaction(this.config, async (client) => {
const target = await client.query(
`SELECT id, username, role, is_active, must_change_password,
last_login_at, created_at, updated_at
FROM users
WHERE organization_id = $1 AND id = $2
FOR UPDATE`,
[actor.organizationId, targetUserId]
);
if (!target.rowCount) throw new AuthError('account_not_found', '账号不存在。', 404);
const before = mapAccount(target.rows[0] as Record<string, unknown>);
const role = input.role === undefined ? before.role : normalizeRole(input.role);
const isActive = input.isActive === undefined ? before.is_active : input.isActive;
const removesActiveAdmin = before.role === 'admin' && before.is_active && (role !== 'admin' || !isActive);
if (actor.id === before.id && (role !== 'admin' || !isActive)) {
throw new AuthError('self_lockout_forbidden', '不能停用或降级当前登录的管理员账号。', 409);
}
if (removesActiveAdmin) {
const activeAdmins = await client.query(
`SELECT id FROM users
WHERE organization_id = $1 AND role = 'admin' AND is_active = true
FOR UPDATE`,
[actor.organizationId]
);
if (activeAdmins.rows.filter((row: Record<string, unknown>) => String(row.id) !== before.id).length === 0) {
throw new AuthError('last_admin_protected', '必须至少保留一个有效管理员账号。', 409);
}
}
const updated = await client.query(
`UPDATE users
SET role = $1, is_active = $2, updated_at = now()
WHERE organization_id = $3 AND id = $4
RETURNING id`,
[role, isActive, actor.organizationId, before.id]
);
if (before.role !== role || before.is_active !== isActive) {
await client.query(
'UPDATE sessions SET revoked_at = now() WHERE user_id = $1 AND revoked_at IS NULL',
[before.id]
);
}
await this.accountAudit(client, actor, 'account.updated', before.id, requestId, {
previous_role: before.role,
role,
previous_active: before.is_active,
active: isActive,
sessions_revoked: before.role !== role || before.is_active !== isActive
});
const account = await loadPublicAccount(client, actor.organizationId, String(updated.rows[0].id));
if (!account) throw new AuthError('account_not_found', '账号更新后未能读取。', 500);
return account;
});
}
async setBusinessRouteAuthorizations(
actor: AuthUser,
targetUserId: string,
routeIds: readonly string[],
expectedRevision: number,
requestId: string
): Promise<PublicAccount> {
this.requireAdmin(actor);
const authorizedRouteIds = normalizeBusinessRouteIds(routeIds);
return withTransaction(this.config, async (client) => {
const target = await client.query(
`SELECT id, role, business_authorization_revision
FROM users
WHERE organization_id = $1 AND id = $2
FOR UPDATE`,
[actor.organizationId, targetUserId]
);
if (!target.rowCount) throw new AuthError('account_not_found', '账号不存在。', 404);
const row = target.rows[0] as Record<string, unknown>;
if (normalizeRole(row.role) === 'admin') {
throw new AuthError('admin_business_authorization_fixed', '管理员固定拥有全部业务权限,无需单独授权。', 409);
}
const currentRevision = Math.max(0, Number(row.business_authorization_revision || 0));
if (currentRevision !== expectedRevision) {
throw new AuthError('business_authorization_revision_conflict', '该账号的业务权限已被其他管理员修改,请刷新后重试。', 409);
}
const previous = await client.query(
`SELECT route_id
FROM user_business_route_authorizations
WHERE organization_id = $1 AND user_id = $2`,
[actor.organizationId, targetUserId]
);
const previousRouteIds = normalizeBusinessRouteIds(
(previous.rows as Record<string, unknown>[]).map((item) => item.route_id)
);
await client.query(
`DELETE FROM user_business_route_authorizations
WHERE organization_id = $1 AND user_id = $2`,
[actor.organizationId, targetUserId]
);
if (authorizedRouteIds.length) {
await client.query(
`INSERT INTO user_business_route_authorizations
(organization_id, user_id, route_id, granted_by)
SELECT $1, $2, route_id, $3
FROM unnest($4::text[]) AS route_id`,
[actor.organizationId, targetUserId, actor.id, authorizedRouteIds]
);
}
await client.query(
`UPDATE users
SET business_authorization_revision = business_authorization_revision + 1,
updated_at = now()
WHERE organization_id = $1 AND id = $2`,
[actor.organizationId, targetUserId]
);
await this.accountAudit(
client,
actor,
'account.business_authorizations_updated',
targetUserId,
requestId,
{
previous_business_route_ids: previousRouteIds,
business_route_ids: authorizedRouteIds,
previous_revision: currentRevision,
revision: currentRevision + 1
}
);
const account = await loadPublicAccount(client, actor.organizationId, targetUserId);
if (!account) throw new AuthError('account_not_found', '账号权限更新后未能读取。', 500);
return account;
});
}
async resetAccountPassword(
actor: AuthUser,
targetUserId: string,
password: string,
mustChangePassword: boolean,
requestId: string
): Promise<void> {
this.requireAdmin(actor);
const passwordHash = await argon2.hash(validatePassword(password), { type: argon2.argon2id });
await withTransaction(this.config, async (client) => {
const target = await client.query(
'SELECT id FROM users WHERE organization_id = $1 AND id = $2 FOR UPDATE',
[actor.organizationId, targetUserId]
);
if (!target.rowCount) throw new AuthError('account_not_found', '账号不存在。', 404);
await client.query(
`UPDATE users
SET password_hash = $1, must_change_password = $2,
password_changed_at = now(), failed_login_count = 0,
locked_until = NULL, updated_at = now()
WHERE id = $3`,
[passwordHash, mustChangePassword, targetUserId]
);
const revoked = await client.query(
'UPDATE sessions SET revoked_at = now() WHERE user_id = $1 AND revoked_at IS NULL',
[targetUserId]
);
await this.accountAudit(client, actor, 'account.password_reset', targetUserId, requestId, {
must_change_password: mustChangePassword,
sessions_revoked: revoked.rowCount || 0
});
});
}
async revokeAccountSessions(actor: AuthUser, targetUserId: string, requestId: string): Promise<number> {
this.requireAdmin(actor);
return withTransaction(this.config, async (client) => {
const target = await client.query(
'SELECT id FROM users WHERE organization_id = $1 AND id = $2 FOR UPDATE',
[actor.organizationId, targetUserId]
);
if (!target.rowCount) throw new AuthError('account_not_found', '账号不存在。', 404);
const revoked = await client.query(
'UPDATE sessions SET revoked_at = now() WHERE user_id = $1 AND revoked_at IS NULL',
[targetUserId]
);
const count = revoked.rowCount || 0;
await this.accountAudit(client, actor, 'account.sessions_revoked', targetUserId, requestId, {
sessions_revoked: count
});
return count;
});
}
async changeOwnPassword(
session: ActiveSession,
currentPassword: string,
newPassword: string,
requestId: string
): Promise<void> {
const validatedNewPassword = validatePassword(newPassword);
const newHash = await argon2.hash(validatedNewPassword, { type: argon2.argon2id });
await withTransaction(this.config, async (client) => {
const target = await client.query(
`SELECT password_hash, is_active
FROM users
WHERE organization_id = $1 AND id = $2
FOR UPDATE`,
[session.user.organizationId, session.user.id]
);
const row = target.rows[0] as Record<string, unknown> | undefined;
if (!row || row.is_active === false || !(await argon2.verify(String(row.password_hash), currentPassword || ''))) {
throw new AuthError('current_password_invalid', '当前密码不正确。', 403);
}
if (await argon2.verify(String(row.password_hash), validatedNewPassword)) {
throw new AuthError('password_unchanged', '新密码不能与当前密码相同。', 409);
}
await client.query(
`UPDATE users
SET password_hash = $1, must_change_password = false,
password_changed_at = now(), failed_login_count = 0,
locked_until = NULL, updated_at = now()
WHERE id = $2`,
[newHash, session.user.id]
);
const revoked = await client.query(
`UPDATE sessions SET revoked_at = now()
WHERE user_id = $1 AND id <> $2 AND revoked_at IS NULL`,
[session.user.id, session.id]
);
await this.accountAudit(client, session.user, 'account.password_changed', session.user.id, requestId, {
other_sessions_revoked: revoked.rowCount || 0
});
});
}
async createSession(user: AuthUser, ipAddress: string, userAgent: string): Promise<AuthSession> {
const token = randomToken(32);
const csrfToken = randomToken(24);
@@ -213,7 +657,8 @@ export class AuthService {
async getActiveSession(token: string | undefined): Promise<ActiveSession | null> {
if (!token) return null;
const result = await getPool(this.config).query(
`SELECT s.id AS session_id, s.csrf_token_hash, u.id, u.organization_id, u.username, u.role
`SELECT s.id AS session_id, s.csrf_token_hash, u.id, u.organization_id, u.username, u.role,
u.must_change_password
FROM sessions s
JOIN users u ON u.id = s.user_id
WHERE s.token_hash = $1
@@ -236,7 +681,8 @@ export class AuthService {
id: row.id,
organization_id: row.organization_id,
username: row.username,
role: row.role
role: row.role,
must_change_password: row.must_change_password
})
};
}