177 lines
5.3 KiB
TypeScript
177 lines
5.3 KiB
TypeScript
import { createHash, randomBytes, randomUUID } from 'node:crypto';
|
|
import type { AuthUser } from './auth.js';
|
|
import { getDatabase } from './database.js';
|
|
|
|
export type TechnicalPermission = 'read' | 'read_write';
|
|
export type TechnicalActor = { id: string; name: string; permissions: TechnicalPermission };
|
|
|
|
function tokenHash(token: string) {
|
|
return createHash('sha256').update(token).digest('hex');
|
|
}
|
|
|
|
export function listTechnicalAccesses() {
|
|
return getDatabase()
|
|
.prepare(
|
|
`SELECT id, name, permissions, created_at_utc AS createdAtUtc,
|
|
last_used_at_utc AS lastUsedAtUtc, revoked_at_utc AS revokedAtUtc FROM technical_accesses ORDER BY name`
|
|
)
|
|
.all() as {
|
|
id: string;
|
|
name: string;
|
|
permissions: TechnicalPermission;
|
|
createdAtUtc: string;
|
|
lastUsedAtUtc: string | null;
|
|
revokedAtUtc: string | null;
|
|
}[];
|
|
}
|
|
|
|
export function createTechnicalAccess(actor: AuthUser, nameValue: string, permissions: string) {
|
|
const name = nameValue.trim();
|
|
if (!name || name.length > 120 || !['read', 'read_write'].includes(permissions))
|
|
throw new Error('INVALID_ACCESS');
|
|
const token = randomBytes(32).toString('base64url');
|
|
const id = randomUUID();
|
|
const now = new Date().toISOString();
|
|
getDatabase()
|
|
.prepare(
|
|
`INSERT INTO technical_accesses
|
|
(id, name, token_hash, permissions, created_by_user_id, created_at_utc) VALUES (?, ?, ?, ?, ?, ?)`
|
|
)
|
|
.run(id, name, tokenHash(token), permissions, actor.id, now);
|
|
auditTechnical(
|
|
{ id: actor.id, name: actor.displayName, permissions: 'read_write' },
|
|
'technical_access.create',
|
|
'technical_access',
|
|
id,
|
|
'user'
|
|
);
|
|
return { id, token };
|
|
}
|
|
|
|
export function updateTechnicalAccess(
|
|
actor: AuthUser,
|
|
id: string,
|
|
nameValue: string,
|
|
permissions: string
|
|
) {
|
|
const name = nameValue.trim();
|
|
if (!name || name.length > 120 || !['read', 'read_write'].includes(permissions))
|
|
throw new Error('INVALID_ACCESS');
|
|
const result = getDatabase()
|
|
.prepare(
|
|
`UPDATE technical_accesses SET name = ?, permissions = ?
|
|
WHERE id = ? AND revoked_at_utc IS NULL`
|
|
)
|
|
.run(name, permissions, id);
|
|
if (!result.changes) throw new Error('NOT_FOUND');
|
|
auditTechnical(
|
|
{ id: actor.id, name: actor.displayName, permissions: 'read_write' },
|
|
'technical_access.update',
|
|
'technical_access',
|
|
id,
|
|
'user'
|
|
);
|
|
}
|
|
|
|
export function revokeTechnicalAccess(actor: AuthUser, id: string) {
|
|
const result = getDatabase()
|
|
.prepare(
|
|
'UPDATE technical_accesses SET revoked_at_utc = ? WHERE id = ? AND revoked_at_utc IS NULL'
|
|
)
|
|
.run(new Date().toISOString(), id);
|
|
if (!result.changes) throw new Error('NOT_FOUND');
|
|
auditTechnical(
|
|
{ id: actor.id, name: actor.displayName, permissions: 'read_write' },
|
|
'technical_access.revoke',
|
|
'technical_access',
|
|
id,
|
|
'user'
|
|
);
|
|
}
|
|
|
|
export function authenticateTechnical(request: Request, write = false): TechnicalActor {
|
|
const authorization = request.headers.get('authorization') ?? '';
|
|
const token = authorization.startsWith('Bearer ') ? authorization.slice(7).trim() : '';
|
|
if (!token) throw new Error('API_UNAUTHORIZED');
|
|
const row = getDatabase()
|
|
.prepare(
|
|
`SELECT id, name, permissions FROM technical_accesses
|
|
WHERE token_hash = ? AND revoked_at_utc IS NULL`
|
|
)
|
|
.get(tokenHash(token)) as TechnicalActor | undefined;
|
|
if (!row) throw new Error('API_UNAUTHORIZED');
|
|
if (write && row.permissions !== 'read_write') throw new Error('API_FORBIDDEN');
|
|
getDatabase()
|
|
.prepare('UPDATE technical_accesses SET last_used_at_utc = ? WHERE id = ?')
|
|
.run(new Date().toISOString(), row.id);
|
|
return row;
|
|
}
|
|
|
|
export function auditTechnical(
|
|
actor: TechnicalActor,
|
|
action: string,
|
|
targetType: string,
|
|
targetId: string,
|
|
actorType: 'technical' | 'user' = 'technical'
|
|
) {
|
|
getDatabase()
|
|
.prepare(
|
|
`INSERT INTO audit_events (id, actor_type, actor_id, action, target_type, target_id, created_at_utc)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?)`
|
|
)
|
|
.run(
|
|
randomUUID(),
|
|
actorType,
|
|
actor.id,
|
|
action.slice(0, 120),
|
|
targetType.slice(0, 80),
|
|
targetId.slice(0, 200),
|
|
new Date().toISOString()
|
|
);
|
|
}
|
|
|
|
export function idempotentResponse(
|
|
actor: TechnicalActor,
|
|
request: Request,
|
|
body: unknown,
|
|
operation: () => { status: number; body: unknown }
|
|
) {
|
|
const key = request.headers.get('idempotency-key')?.trim() ?? '';
|
|
if (!key || key.length > 200) throw new Error('API_IDEMPOTENCY_REQUIRED');
|
|
const hash = createHash('sha256')
|
|
.update(`${request.method} ${new URL(request.url).pathname}\n${JSON.stringify(body)}`)
|
|
.digest('hex');
|
|
const database = getDatabase();
|
|
return database.transaction(() => {
|
|
const existing = database
|
|
.prepare(
|
|
'SELECT request_hash, status_code, response_json FROM api_idempotency WHERE access_id = ? AND request_key = ?'
|
|
)
|
|
.get(actor.id, key) as
|
|
{ request_hash: string; status_code: number; response_json: string } | undefined;
|
|
if (existing) {
|
|
if (existing.request_hash !== hash) throw new Error('API_IDEMPOTENCY_CONFLICT');
|
|
return {
|
|
status: existing.status_code,
|
|
body: JSON.parse(existing.response_json),
|
|
duplicate: true
|
|
};
|
|
}
|
|
const result = operation();
|
|
database
|
|
.prepare(
|
|
`INSERT INTO api_idempotency
|
|
(access_id, request_key, request_hash, status_code, response_json, created_at_utc) VALUES (?, ?, ?, ?, ?, ?)`
|
|
)
|
|
.run(
|
|
actor.id,
|
|
key,
|
|
hash,
|
|
result.status,
|
|
JSON.stringify(result.body),
|
|
new Date().toISOString()
|
|
);
|
|
return { ...result, duplicate: false };
|
|
})();
|
|
}
|