Skip to content
Merged
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
8 changes: 8 additions & 0 deletions .changeset/iam-report-access-denied.md
Original file line number Diff line number Diff line change
@@ -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).
1 change: 1 addition & 0 deletions docs/catalog.json
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,7 @@
"iam.listInvitations",
"iam.listRoles",
"iam.previewEffectivePermissions",
"iam.reportAccessDenied",
"iam.setRolePermissions",
"iam.unassignRole",
"iam.updateRole"
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/contracts/adapters/rate-limit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Expand Down
70 changes: 69 additions & 1 deletion packages/core/src/iam/__tests__/iam.service.int.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<typeof vi.fn>).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());
Expand Down
5 changes: 5 additions & 0 deletions packages/core/src/iam/contract/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof AdminRoleSchema>;
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/iam/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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),
),
Expand Down
5 changes: 5 additions & 0 deletions packages/core/src/iam/router/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
}),
});
}
28 changes: 28 additions & 0 deletions packages/core/src/iam/service/iam.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -262,6 +265,7 @@ export class IamService {
private readonly events: EventBus,
private readonly email: SendEmailPort,
private readonly sessionCommands?: SessionCommands,
private readonly rateLimiter?: RateLimiterAdapter<RateLimitKey>,
) {}

private async callerGrants(caller: Caller) {
Expand Down Expand Up @@ -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 };
}
}
Loading