diff --git a/.changeset/iam-report-access-denied.md b/.changeset/iam-report-access-denied.md new file mode 100644 index 00000000..95b47780 --- /dev/null +++ b/.changeset/iam-report-access-denied.md @@ -0,0 +1,8 @@ +--- +'@openora/core': patch +--- + +Add `iam.reportAccessDenied` so a consumer's client-side page guard (e.g. `PermissionGate`) can produce a real audit signal: the guard's own redirect never reaches the server, so on its own it left no trail. + +- `POST /iam/report-access-denied` takes `{ resource, level }`, re-verifies the caller's actual permission level server-side, and only emits `identity.user.unauthorized_access` (the same event `AdminGuard` denials produce) when the caller genuinely lacks that level - a caller who actually holds the permission cannot produce a false denial entry. +- Rate-limited per caller/resource (throttle only - a throttled repeat is silently skipped, never surfaced as an error, since this is a fire-and-forget report). diff --git a/docs/catalog.json b/docs/catalog.json index 3ac13d1d..8ef806b5 100644 --- a/docs/catalog.json +++ b/docs/catalog.json @@ -186,6 +186,7 @@ "iam.listInvitations", "iam.listRoles", "iam.previewEffectivePermissions", + "iam.reportAccessDenied", "iam.setRolePermissions", "iam.unassignRole", "iam.updateRole" diff --git a/packages/core/src/contracts/adapters/rate-limit.ts b/packages/core/src/contracts/adapters/rate-limit.ts index de9f5e2e..1de8e05a 100644 --- a/packages/core/src/contracts/adapters/rate-limit.ts +++ b/packages/core/src/contracts/adapters/rate-limit.ts @@ -23,6 +23,7 @@ export const RATE_LIMIT_KEYS = { WALLET_MUTATION: 'wallet-mutation', CHAT_ROOM_JOIN: 'chat-room-join', CHAT_SEND: 'chat-send', + REPORT_ACCESS_DENIED: 'report-access-denied', } as const; export type RateLimitKeyPrefix = (typeof RATE_LIMIT_KEYS)[keyof typeof RATE_LIMIT_KEYS]; diff --git a/packages/core/src/iam/__tests__/iam.service.int.test.ts b/packages/core/src/iam/__tests__/iam.service.int.test.ts index 299fabca..af2b5cdd 100644 --- a/packages/core/src/iam/__tests__/iam.service.int.test.ts +++ b/packages/core/src/iam/__tests__/iam.service.int.test.ts @@ -2,7 +2,13 @@ import { randomUUID } from 'node:crypto'; import { describe, it, expect, vi, beforeAll, afterAll, beforeEach } from 'vitest'; import { sql, eq } from 'drizzle-orm'; import { mock, NO_CLIENT_META, makeEventBus } from '../../testing/mock.js'; -import { statement, findOneOrThrow, RedisCache, type ResourceName } from '@openora/core/server'; +import { + statement, + findOneOrThrow, + RedisCache, + RedisRateLimiter, + type ResourceName, +} from '@openora/core/server'; import type { SendEmailPort, SessionCommands } from '@openora/core/contracts'; import { createTestDb, createTestRedis, type TestDb, type TestRedis } from '@openora/core/testing'; import { migrate as migrateIam } from '@openora/core/iam/migrate'; @@ -658,6 +664,68 @@ describe('IamService paginated lists', () => { }); }); +describe('IamService.reportAccessDenied', () => { + it('records and audits a genuine denial (caller lacks the resource/level)', async () => { + const events = makeEventBus(); + const svc = new IamService(db.drizzle, events, makeEmail()); + + const result = await svc.reportAccessDenied({ + resource: 'withdrawal', + level: 'read', + caller: SUPPORT_CALLER, + }); + + expect(result).toEqual({ recorded: true }); + expect(events.emit).toHaveBeenCalledWith( + 'identity.user.unauthorized_access', + expect.objectContaining({ + userId: SUPPORT_CALLER.userId, + resource: 'withdrawal', + action: 'access', + role: 'support', + }), + ); + }); + + it('does not record or emit when the caller genuinely holds the permission', async () => { + const events = makeEventBus(); + const svc = new IamService(db.drizzle, events, makeEmail()); + + const result = await svc.reportAccessDenied({ + resource: 'player', + level: 'read_write', + caller: ADMIN_CALLER, + }); + + expect(result).toEqual({ recorded: false }); + expect(events.emit).not.toHaveBeenCalled(); + }); + + it('with a rate limiter wired, a repeat report within the window is throttled and not recorded again', async () => { + const events = makeEventBus(); + const limiter = new RedisRateLimiter(redis.client); + const svc = new IamService(db.drizzle, events, makeEmail(), undefined, limiter); + + const first = await svc.reportAccessDenied({ + resource: 'withdrawal', + level: 'read', + caller: SUPPORT_CALLER, + }); + const second = await svc.reportAccessDenied({ + resource: 'withdrawal', + level: 'read', + caller: SUPPORT_CALLER, + }); + + expect(first).toEqual({ recorded: true }); + expect(second).toEqual({ recorded: false }); + const denialCalls = (events.emit as ReturnType).mock.calls.filter( + (c) => c[0] === 'identity.user.unauthorized_access', + ); + expect(denialCalls).toHaveLength(1); + }); +}); + describe('IamService.forceLogout', () => { it('rejects a non-super-admin caller', async () => { const svc = new IamService(db.drizzle, makeEventBus(), makeEmail()); diff --git a/packages/core/src/iam/contract/index.ts b/packages/core/src/iam/contract/index.ts index ddc69216..c440c1fd 100644 --- a/packages/core/src/iam/contract/index.ts +++ b/packages/core/src/iam/contract/index.ts @@ -189,6 +189,11 @@ export const iamContract = { getMyPermissions: oc .route({ method: 'GET', path: '/iam/my-permissions' }) .output(EffectivePermissionsSchema), + + reportAccessDenied: oc + .route({ method: 'POST', path: '/iam/report-access-denied' }) + .input(GrantInputSchema) + .output(z.object({ recorded: z.boolean() })), }; export type AdminRole = z.infer; diff --git a/packages/core/src/iam/plugin.ts b/packages/core/src/iam/plugin.ts index 7beda8ff..e6ccf4a5 100644 --- a/packages/core/src/iam/plugin.ts +++ b/packages/core/src/iam/plugin.ts @@ -6,6 +6,7 @@ import { SEND_EMAIL, SESSION_COMMANDS, CACHE, + RATE_LIMITER, domainEventSchemas, } from '@openora/core/contracts'; import { IamService, DbAdminPermissionResolver } from './service/iam.service.js'; @@ -66,6 +67,7 @@ export default { c.get(EVENT_BUS), c.get(SEND_EMAIL), c.get(SESSION_COMMANDS), + c.get(RATE_LIMITER), ), c.get(ADMIN_GUARD), ), diff --git a/packages/core/src/iam/router/index.ts b/packages/core/src/iam/router/index.ts index 78709188..6b22a118 100644 --- a/packages/core/src/iam/router/index.ts +++ b/packages/core/src/iam/router/index.ts @@ -114,5 +114,10 @@ export function createIamRouter(svc: IamService, adminGuard: AdminGuard) { const caller = await adminGuard.assert(context); return svc.previewEffectivePermissions({ userId: caller.userId }); }), + + reportAccessDenied: os.reportAccessDenied.handler(async ({ input, context }) => { + const caller = await adminGuard.assert(context); + return svc.reportAccessDenied({ ...input, caller }); + }), }); } diff --git a/packages/core/src/iam/service/iam.service.ts b/packages/core/src/iam/service/iam.service.ts index a139408f..ea932f4d 100644 --- a/packages/core/src/iam/service/iam.service.ts +++ b/packages/core/src/iam/service/iam.service.ts @@ -33,7 +33,10 @@ import type { SortOrder, PageQuery, PaginationOptions, + RateLimiterAdapter, + RateLimitKey, } from '@openora/core/contracts'; +import { RATE_LIMIT_KEYS, makeRateLimitKey } from '@openora/core/contracts'; import { adminRole, adminRolePermission, @@ -262,6 +265,7 @@ export class IamService { private readonly events: EventBus, private readonly email: SendEmailPort, private readonly sessionCommands?: SessionCommands, + private readonly rateLimiter?: RateLimiterAdapter, ) {} private async callerGrants(caller: Caller) { @@ -872,4 +876,28 @@ export class IamService { await this.sessionCommands.revokeAll(input.userId, input.caller.userId); return { success: true as const }; } + + async reportAccessDenied(input: { + resource: string; + level: PermissionLevel; + caller: Caller; + }): Promise<{ recorded: boolean }> { + const grantMap = grantsToLevelMap(await this.callerGrants(input.caller)); + const actualLevel = grantMap[input.resource] ?? 'no_access'; + if (isLevelSufficient(actualLevel, input.level)) { + return { recorded: false }; + } + if (this.rateLimiter) { + const key = makeRateLimitKey( + RATE_LIMIT_KEYS.REPORT_ACCESS_DENIED, + `${input.caller.userId}:${input.resource}`, + ); + const { allowed } = await this.rateLimiter.consume(key, { limit: 1, windowMs: 60_000 }); + if (!allowed) { + return { recorded: false }; + } + } + this.emitDenied(input.caller, input.resource, 'access'); + return { recorded: true }; + } }