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
97 changes: 97 additions & 0 deletions frontend/src/__tests__/api-permissions.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
/**
* Runtime-shape-validation tests for getUserPermissions() (CR #922 F3).
*
* canAccess() iterates user.effectivePermissions with
* `for (const p of user.effectivePermissions)` and reads p.action /
* p.resource. A non-array `permissions` (or null/non-object entries,
* or non-string action/resource) would throw outside app.ts's
* fetch/merge try/catch and crash the bootstrap path. The validator
* must reject every such shape so the caller falls back to the safe
* group-membership gating instead.
*/
import { getUserPermissions } from '../api/auth';

beforeEach(() => {
global.fetch = jest.fn();
localStorage.setItem('auth_token', 'tok');
});
afterEach(() => {
jest.restoreAllMocks();
localStorage.clear();
});

function mockFetchOk(body: unknown): void {
(global.fetch as jest.Mock).mockResolvedValue({
ok: true,
status: 200,
json: async () => body,
});
}

describe('getUserPermissions()', () => {
test('returns parsed UserPermissionsResponse on a well-formed payload', async () => {
mockFetchOk({
permissions: [
{ action: 'view', resource: 'recommendations' },
{ action: 'view', resource: 'plans' },
],
is_admin: false,
});
const res = await getUserPermissions();
expect(res.is_admin).toBe(false);
expect(res.permissions).toEqual([
{ action: 'view', resource: 'recommendations' },
{ action: 'view', resource: 'plans' },
]);
});

test('returns is_admin=true with {admin,*} entry on admin payload', async () => {
mockFetchOk({
permissions: [{ action: 'admin', resource: '*' }],
is_admin: true,
});
const res = await getUserPermissions();
expect(res.is_admin).toBe(true);
expect(res.permissions).toEqual([{ action: 'admin', resource: '*' }]);
});

test('rejects when response is not an object (e.g. string)', async () => {
mockFetchOk('not-an-object');
await expect(getUserPermissions()).rejects.toThrow(/was not an object/);
});

test('rejects when response is null', async () => {
mockFetchOk(null);
await expect(getUserPermissions()).rejects.toThrow(/was not an object/);
});

test('rejects when permissions is not an array (e.g. truthy non-array)', async () => {
mockFetchOk({ permissions: { 0: { action: 'view', resource: 'plans' } }, is_admin: false });
await expect(getUserPermissions()).rejects.toThrow(/permissions is not an array/);
});

test('rejects when a permission entry is null', async () => {
mockFetchOk({ permissions: [null], is_admin: false });
await expect(getUserPermissions()).rejects.toThrow(/permissions\[0\] is not an object/);
});

test('rejects when a permission entry is missing action', async () => {
mockFetchOk({ permissions: [{ resource: 'plans' }], is_admin: false });
await expect(getUserPermissions()).rejects.toThrow(/permissions\[0\]\.action is not a string/);
});

test('rejects when a permission entry has non-string resource', async () => {
mockFetchOk({ permissions: [{ action: 'view', resource: 42 }], is_admin: false });
await expect(getUserPermissions()).rejects.toThrow(/permissions\[0\]\.resource is not a string/);
});

test('rejects when is_admin is missing', async () => {
mockFetchOk({ permissions: [] });
await expect(getUserPermissions()).rejects.toThrow(/is_admin is not a boolean/);
});

test('rejects when is_admin is not boolean (e.g. "true" string)', async () => {
mockFetchOk({ permissions: [], is_admin: 'true' });
await expect(getUserPermissions()).rejects.toThrow(/is_admin is not a boolean/);
});
});
93 changes: 82 additions & 11 deletions frontend/src/__tests__/permissions.test.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,19 @@
/**
* Permissions helper tests.
*
* PR #912 replaced role-based gating with group-membership-based
* gating. isAdmin() now returns true when the current user is a member
* of the Administrators group (UUID 00000000-0000-5000-8000-000000000001).
* canAccess() is currently a pass-through to isAdmin() -- non-admin users
* always get false because the /me/permissions endpoint is deferred.
* Issue #917: canAccess() now consults user.effectivePermissions when
* populated (fetched from GET /api/auth/me/permissions on bootstrap).
* When effectivePermissions is absent (loading race) it falls back to
* the group-membership admin check: admin passes, others block.
*
* isAdmin() returns true when the current user is a member of the
* Administrators group (UUID 00000000-0000-5000-8000-000000000001).
*
* getRolePermissions() is kept for the effective-permissions display in
* the admin Users page and still returns the same sets as before.
*/
import { canAccess, getRolePermissions, isAdmin, ADMINISTRATORS_GROUP_ID } from '../permissions';
import type { PermissionEntry } from '../api/types';

jest.mock('../state', () => ({
getCurrentUser: jest.fn(),
Expand All @@ -22,9 +25,9 @@ const ADMIN_GID = ADMINISTRATORS_GROUP_ID;
const STD_GID = '00000000-0000-5000-8000-000000000005';
const RO_GID = '00000000-0000-5000-8000-000000000006';

const mockUserWithGroups = (groups: string[]) => {
const mockUserWithGroups = (groups: string[], effectivePermissions?: PermissionEntry[]) => {
(state.getCurrentUser as jest.Mock).mockReturnValue(
{ id: 'u1', email: 'u@example.com', groups },
{ id: 'u1', email: 'u@example.com', groups, effectivePermissions },
);
};

Expand Down Expand Up @@ -122,8 +125,8 @@ describe('permissions', () => {
});
});

describe('canAccess', () => {
test('Administrators group member passes all checks', () => {
describe('canAccess - fallback (effectivePermissions absent)', () => {
test('Administrators group member passes all checks via group-membership fallback', () => {
mockUserWithGroups([ADMIN_GID]);
expect(canAccess('admin', '*')).toBe(true);
expect(canAccess('view', 'users')).toBe(true);
Expand All @@ -132,14 +135,15 @@ describe('permissions', () => {
expect(canAccess('view', 'accounts')).toBe(true);
});

test('Standard Users group member fails all checks (no /me/permissions endpoint yet)', () => {
test('Standard Users group member blocked during loading (effectivePermissions undefined)', () => {
// Before /me/permissions returns, non-admins are blocked (fails closed).
mockUserWithGroups([STD_GID]);
expect(canAccess('view', 'recommendations')).toBe(false);
expect(canAccess('view', 'plans')).toBe(false);
expect(canAccess('admin', '*')).toBe(false);
});

test('Read-Only Users group member fails all checks', () => {
test('Read-Only Users group member blocked during loading', () => {
mockUserWithGroups([RO_GID]);
expect(canAccess('view', 'recommendations')).toBe(false);
expect(canAccess('admin', '*')).toBe(false);
Expand All @@ -158,4 +162,71 @@ describe('permissions', () => {
expect(canAccess('create', 'plans')).toBe(false);
});
});

describe('canAccess - effective permissions (post /me/permissions fetch)', () => {
test('Standard Users group member gets explicit grants from effectivePermissions', () => {
const perms: PermissionEntry[] = [
{ action: 'view', resource: 'recommendations' },
{ action: 'view', resource: 'plans' },
{ action: 'view', resource: 'purchases' },
{ action: 'view', resource: 'history' },
{ action: 'create', resource: 'plans' },
{ action: 'cancel-own', resource: 'purchases' },
];
mockUserWithGroups([STD_GID], perms);
expect(canAccess('view', 'recommendations')).toBe(true);
expect(canAccess('view', 'plans')).toBe(true);
expect(canAccess('view', 'purchases')).toBe(true);
expect(canAccess('view', 'history')).toBe(true);
expect(canAccess('create', 'plans')).toBe(true);
expect(canAccess('cancel-own', 'purchases')).toBe(true);
});

test('Standard Users group member is denied for permissions not in effective set', () => {
const perms: PermissionEntry[] = [
{ action: 'view', resource: 'recommendations' },
];
mockUserWithGroups([STD_GID], perms);
expect(canAccess('delete', 'plans')).toBe(false);
expect(canAccess('execute', 'purchases')).toBe(false);
expect(canAccess('admin', '*')).toBe(false);
expect(canAccess('view', 'users')).toBe(false);
});

test('admin wildcard in effectivePermissions grants everything', () => {
const perms: PermissionEntry[] = [
{ action: 'admin', resource: '*' },
];
mockUserWithGroups([ADMIN_GID], perms);
expect(canAccess('admin', '*')).toBe(true);
expect(canAccess('delete', 'plans')).toBe(true);
expect(canAccess('view', 'users')).toBe(true);
expect(canAccess('execute', 'purchases')).toBe(true);
});

test('empty effectivePermissions array denies everything', () => {
mockUserWithGroups([STD_GID], []);
expect(canAccess('view', 'recommendations')).toBe(false);
expect(canAccess('admin', '*')).toBe(false);
});

test('multi-group user gets the union: all granted actions from both groups', () => {
const perms: PermissionEntry[] = [
{ action: 'view', resource: 'recommendations' },
{ action: 'view', resource: 'plans' },
{ action: 'create', resource: 'plans' },
];
mockUserWithGroups([STD_GID, RO_GID], perms);
expect(canAccess('view', 'recommendations')).toBe(true);
expect(canAccess('view', 'plans')).toBe(true);
expect(canAccess('create', 'plans')).toBe(true);
expect(canAccess('delete', 'plans')).toBe(false);
});

test('null user (logged out) fails regardless of any permissions', () => {
mockNoUser();
expect(canAccess('view', 'recommendations')).toBe(false);
expect(canAccess('admin', '*')).toBe(false);
});
});
});
46 changes: 46 additions & 0 deletions frontend/src/api/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import { apiRequest, getAuthHeaders, setAuthToken, setCsrfToken, clearAuth, addC
import type {
LoginResponse,
User,
PermissionEntry,
UserPermissionsResponse,
PublicInfo,
MFASetupResponse,
MFARecoveryCodesResponse,
Expand Down Expand Up @@ -110,6 +112,50 @@ export async function getCurrentUser(): Promise<User> {
return apiRequest<User>('/auth/me');
}

/**
* Fetch the effective permission set for the current user from
* GET /api/auth/me/permissions (issue #917). Returns the full
* UserPermissionsResponse including is_admin.
*
* Called on login/bootstrap and the result is merged onto the
* current-user state so canAccess() can consult it.
*
* Runtime-validates the response shape (same pattern as setupMFA /
* getResetTokenStatus) so a malicious or misconfigured server can't
* inject a non-array `permissions` value that would later crash
* canAccess()'s `for (const p of user.effectivePermissions)` loop. On
* any shape mismatch this throws so the caller (app.ts) falls back to
* the safe group-membership gating instead of rendering with broken
* permission state.
*/
export async function getUserPermissions(): Promise<UserPermissionsResponse> {
const data = await apiRequest<unknown>('/auth/me/permissions');
if (data === null || typeof data !== 'object') {
throw new Error(`getUserPermissions response was not an object: ${JSON.stringify(data)}`);
}
const { permissions, is_admin } = data as { permissions?: unknown; is_admin?: unknown };
if (!Array.isArray(permissions)) {
throw new Error(`getUserPermissions response.permissions is not an array: ${JSON.stringify(permissions)}`);
}
const validated: PermissionEntry[] = permissions.map((entry, i) => {
if (entry === null || typeof entry !== 'object') {
throw new Error(`getUserPermissions response.permissions[${i}] is not an object: ${JSON.stringify(entry)}`);
}
const { action, resource } = entry as { action?: unknown; resource?: unknown };
if (typeof action !== 'string') {
throw new Error(`getUserPermissions response.permissions[${i}].action is not a string: ${JSON.stringify(action)}`);
}
if (typeof resource !== 'string') {
throw new Error(`getUserPermissions response.permissions[${i}].resource is not a string: ${JSON.stringify(resource)}`);
}
return { action, resource };
});
if (typeof is_admin !== 'boolean') {
throw new Error(`getUserPermissions response.is_admin is not a boolean: ${JSON.stringify(is_admin)}`);
}
return { permissions: validated, is_admin };
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

/**
* Request password reset
*/
Expand Down
3 changes: 3 additions & 0 deletions frontend/src/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ export type {
PaymentOption,
RampSchedule,
User,
PermissionEntry,
UserPermissionsResponse,
LoginResponse,
DashboardSummary,
UpcomingPurchase,
Expand Down Expand Up @@ -79,6 +81,7 @@ export {
login,
logout,
getCurrentUser,
getUserPermissions,
requestPasswordReset,
resetPassword,
getResetTokenStatus,
Expand Down
24 changes: 24 additions & 0 deletions frontend/src/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,25 @@ export type PaymentOption = 'no-upfront' | 'partial-upfront' | 'all-upfront';
export type RampSchedule = 'immediate' | 'weekly-25pct' | 'monthly-10pct' | 'custom';

// User types

/**
* PermissionEntry matches the JSON shape returned by GET /api/auth/me/permissions.
* Both action and resource are string to stay forward-compatible with new
* constants added to internal/auth/types.go without a frontend change.
*/
export interface PermissionEntry {
action: string;
resource: string;
}

/**
* UserPermissionsResponse is the shape of GET /api/auth/me/permissions.
*/
export interface UserPermissionsResponse {
permissions: PermissionEntry[];
is_admin: boolean;
}

export interface User {
id: string;
email: string;
Expand All @@ -21,6 +40,11 @@ export interface User {
// responses may not include it. The profile/MFA section in
// auth.ts treats `mfa_enabled === true` (strict) as "enabled".
mfa_enabled?: boolean;
// Effective permissions fetched from GET /api/auth/me/permissions
// on login/bootstrap (issue #917). Undefined until the fetch
// completes; canAccess() falls back to group-membership-only
// gating (admins pass, others blocked) while this is loading.
effectivePermissions?: PermissionEntry[];
}

export interface LoginResponse {
Expand Down
15 changes: 15 additions & 0 deletions frontend/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,21 @@ export async function init(): Promise<void> {
try {
const user = await api.getCurrentUser();
state.setCurrentUser(user);

// Fetch the effective permission set from /api/auth/me/permissions
// (issue #917) and merge it onto the user state so canAccess() can
// consult the real group-derived set instead of blocking non-admins.
// Best-effort: a failure here must not prevent the app from loading.
try {
const permsResp = await api.getUserPermissions();
const updatedUser = state.getCurrentUser();
if (updatedUser) {
state.setCurrentUser({ ...updatedUser, effectivePermissions: permsResp.permissions });
}
} catch (permErr) {
console.warn('Failed to fetch effective permissions, using fallback gating:', permErr);
}

initRouter();
// Deep-link check BEFORE tab routing: the path /purchases/{approve,
// cancel}/:id?token=… isn't a tab — it's a one-shot action landing
Expand Down
Loading
Loading