Some checks are pending
Bidi Control Character Guard / bidi-control-guard (push) Waiting to run
Circular Dependency Check / Check for new circular dependencies (push) Waiting to run
Citus Migration Smoke / Combined migrations on single-node Citus (push) Waiting to run
E2E Fresh Install Tests / fresh-install-e2e (push) Waiting to run
ext-v2 guardrails / Run ext-v2 guard and ESLint (push) Waiting to run
Integration Tests / Check for relevant changes (push) Waiting to run
Integration Tests / ${{ (github.event_name == 'schedule' || github.event.inputs.suite == 'full') && 'Full integration suite' || 'Tier-1 integration subset' }} (push) Blocked by required conditions
Mobile checks / Mobile lint + typecheck (push) Waiting to run
Mobile checks / Mobile unit tests (push) Waiting to run
Mobile checks / Mobile dependency audit (report) (push) Waiting to run
Mobile checks / Mobile reproducibility checks (push) Waiting to run
Secrets guard (env backups) / Ensure no tracked env backup files (push) Waiting to run
Temporal Readiness / fast-readiness (push) Waiting to run
Temporal Readiness / docker-parity (push) Waiting to run
TypeScript Type Check / Nx affected typecheck (push) Waiting to run
Unit Tests / Skipped-test budget (push) Waiting to run
Unit Tests / Nx affected unit tests (push) Waiting to run
Unit Tests / Server unit coverage (informational) (push) Waiting to run
Validate Tenant Management Schema / Check for relevant changes (push) Waiting to run
Validate Tenant Management Schema / Validate Tenant Management Schema (push) Blocked by required conditions
EE Workflows Build Guard / ee-workflows-build-guard (push) Waiting to run
Excluded: .git, node_modules, secrets/, compose.env, assemblyscript tgz Source: /opt/alga-psa on psa.joliet.tech
300 lines
10 KiB
TypeScript
300 lines
10 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
const hasPermissionMock = vi.hoisted(() => vi.fn());
|
|
const featureFlagMock = vi.hoisted(() => vi.fn());
|
|
const reportsToMock = vi.hoisted(() => vi.fn());
|
|
const resolveBundleRulesMock = vi.hoisted(() => vi.fn(async () => []));
|
|
|
|
vi.mock('@alga-psa/auth', () => ({
|
|
hasPermission: (...args: unknown[]) => hasPermissionMock(...args),
|
|
}));
|
|
|
|
vi.mock('@alga-psa/core', () => ({
|
|
isFeatureFlagEnabled: (...args: unknown[]) => featureFlagMock(...args),
|
|
}));
|
|
|
|
vi.mock('@alga-psa/db', () => ({
|
|
User: {
|
|
getReportsToSubordinateIds: (...args: unknown[]) => reportsToMock(...args),
|
|
},
|
|
}));
|
|
|
|
vi.mock('@alga-psa/authorization/bundles/service', () => ({
|
|
resolveBundleNarrowingRulesForEvaluation: (...args: unknown[]) => resolveBundleRulesMock(...args),
|
|
}));
|
|
|
|
vi.mock('@alga-psa/authorization/kernel', () => {
|
|
class BuiltinAuthorizationKernelProvider {
|
|
relationshipRules?: Array<{ template: string }>;
|
|
mutationGuards?: Array<(input: any) => { allowed: boolean }>;
|
|
|
|
constructor(config?: { relationshipRules?: Array<{ template: string }>; mutationGuards?: Array<(input: any) => { allowed: boolean }> }) {
|
|
this.relationshipRules = config?.relationshipRules;
|
|
this.mutationGuards = config?.mutationGuards;
|
|
}
|
|
}
|
|
|
|
class BundleAuthorizationKernelProvider {
|
|
resolveRules: (input: unknown) => Promise<Array<Record<string, unknown>>>;
|
|
|
|
constructor(config: { resolveRules: (input: unknown) => Promise<Array<Record<string, unknown>>> }) {
|
|
this.resolveRules = config.resolveRules;
|
|
}
|
|
}
|
|
|
|
class RequestLocalAuthorizationCache {}
|
|
|
|
const createAuthorizationKernel = (config: {
|
|
builtinProvider?: BuiltinAuthorizationKernelProvider;
|
|
bundleProvider?: BundleAuthorizationKernelProvider;
|
|
}) => ({
|
|
authorizeResource: async (input: any) => {
|
|
const rules = config.bundleProvider ? await config.bundleProvider.resolveRules(input) : [];
|
|
const managedOnly = config.builtinProvider?.relationshipRules?.some((rule) => rule.template === 'managed');
|
|
const builtinAllowed = managedOnly
|
|
? Array.isArray(input.subject.managedUserIds) && input.subject.managedUserIds.includes(input.record.ownerUserId)
|
|
: true;
|
|
|
|
let bundleAllowed = true;
|
|
const matchingRules = rules.filter(
|
|
(rule) => rule.resource === input.resource.type && rule.action === input.resource.action
|
|
);
|
|
for (const rule of matchingRules) {
|
|
if (rule.templateKey === 'own') {
|
|
bundleAllowed = bundleAllowed && input.record.ownerUserId === input.subject.userId;
|
|
}
|
|
if (rule.templateKey === 'managed') {
|
|
bundleAllowed =
|
|
bundleAllowed &&
|
|
Array.isArray(input.subject.managedUserIds) &&
|
|
input.subject.managedUserIds.includes(input.record.ownerUserId);
|
|
}
|
|
if (rule.templateKey === 'own_or_managed') {
|
|
const isOwn = input.record.ownerUserId === input.subject.userId;
|
|
const isManaged =
|
|
Array.isArray(input.subject.managedUserIds) &&
|
|
input.subject.managedUserIds.includes(input.record.ownerUserId);
|
|
bundleAllowed = bundleAllowed && (isOwn || isManaged);
|
|
}
|
|
}
|
|
|
|
return {
|
|
allowed: builtinAllowed && bundleAllowed,
|
|
reasons: [],
|
|
scope: { allowAll: builtinAllowed && bundleAllowed, denied: !(builtinAllowed && bundleAllowed), constraints: [] },
|
|
redactedFields: [],
|
|
};
|
|
},
|
|
authorizeMutation: async (input: any) => {
|
|
let builtinAllowed = true;
|
|
for (const guard of config.builtinProvider?.mutationGuards ?? []) {
|
|
const result = guard(input);
|
|
if (!result.allowed) {
|
|
builtinAllowed = false;
|
|
break;
|
|
}
|
|
}
|
|
|
|
const rules = config.bundleProvider ? await config.bundleProvider.resolveRules(input) : [];
|
|
const matchingRules = rules.filter(
|
|
(rule) => rule.resource === input.resource.type && rule.action === input.resource.action
|
|
);
|
|
let bundleAllowed = true;
|
|
for (const rule of matchingRules) {
|
|
if (
|
|
rule.constraintKey === 'not_self_approver' &&
|
|
input.record.ownerUserId === input.subject.userId
|
|
) {
|
|
bundleAllowed = false;
|
|
}
|
|
}
|
|
|
|
return {
|
|
allowed: builtinAllowed && bundleAllowed,
|
|
reasons: [],
|
|
scope: { allowAll: builtinAllowed && bundleAllowed, denied: !(builtinAllowed && bundleAllowed), constraints: [] },
|
|
redactedFields: [],
|
|
};
|
|
},
|
|
});
|
|
|
|
return {
|
|
BuiltinAuthorizationKernelProvider,
|
|
BundleAuthorizationKernelProvider,
|
|
RequestLocalAuthorizationCache,
|
|
createAuthorizationKernel,
|
|
};
|
|
});
|
|
|
|
import {
|
|
assertCanActOnBehalf,
|
|
assertCanApproveSubject,
|
|
resolveManagedSubjectUserIds,
|
|
} from '../src/actions/timeEntryDelegationAuth';
|
|
|
|
type TestUser = {
|
|
user_id: string;
|
|
user_type: 'internal' | 'client';
|
|
clientId?: string | null;
|
|
};
|
|
|
|
function buildDb(managedIds: string[]) {
|
|
return ((table: string) => {
|
|
if (table === 'teams') {
|
|
return {
|
|
join: vi.fn().mockReturnThis(),
|
|
where: vi.fn().mockReturnThis(),
|
|
select: vi.fn(async () => managedIds.map((user_id) => ({ user_id }))),
|
|
};
|
|
}
|
|
|
|
if (table === 'users') {
|
|
return {
|
|
where: vi.fn().mockReturnThis(),
|
|
select: vi.fn(async () => []),
|
|
};
|
|
}
|
|
|
|
return {
|
|
where: vi.fn().mockReturnThis(),
|
|
select: vi.fn(async () => []),
|
|
first: vi.fn(async () => undefined),
|
|
};
|
|
}) as any;
|
|
}
|
|
|
|
describe('time authorization delegation and approval contracts', () => {
|
|
beforeEach(() => {
|
|
hasPermissionMock.mockReset();
|
|
featureFlagMock.mockReset();
|
|
reportsToMock.mockReset();
|
|
resolveBundleRulesMock.mockReset();
|
|
|
|
featureFlagMock.mockResolvedValue(false);
|
|
reportsToMock.mockResolvedValue([]);
|
|
resolveBundleRulesMock.mockResolvedValue([]);
|
|
});
|
|
|
|
it('T017: preserves self, manager, reports-to, and tenant-wide delegation semantics', async () => {
|
|
const actor: TestUser = { user_id: 'u-1', user_type: 'internal' };
|
|
const db = buildDb(['u-2']);
|
|
|
|
await expect(assertCanActOnBehalf(actor as any, 'tenant-1', 'u-1', db)).resolves.toBe('self');
|
|
|
|
hasPermissionMock.mockImplementation(async (_user: unknown, resource: string, action: string) => {
|
|
if (resource !== 'timesheet') return false;
|
|
if (action === 'approve') return true;
|
|
if (action === 'read_all') return false;
|
|
return false;
|
|
});
|
|
|
|
await expect(assertCanActOnBehalf(actor as any, 'tenant-1', 'u-2', db)).resolves.toBe('manager');
|
|
|
|
hasPermissionMock.mockImplementation(async (_user: unknown, resource: string, action: string) => {
|
|
if (resource !== 'timesheet') return false;
|
|
if (action === 'approve') return true;
|
|
if (action === 'read_all') return true;
|
|
return false;
|
|
});
|
|
|
|
await expect(assertCanActOnBehalf(actor as any, 'tenant-1', 'u-9', db)).resolves.toBe('tenant-wide');
|
|
|
|
featureFlagMock.mockResolvedValue(true);
|
|
reportsToMock.mockResolvedValue(['u-77']);
|
|
const managedIds = await resolveManagedSubjectUserIds(db, 'tenant-1', actor as any);
|
|
expect(managedIds).toEqual(expect.arrayContaining(['u-2', 'u-77']));
|
|
});
|
|
|
|
it('T018: premium bundle rules can narrow delegation but cannot broaden beyond builtin model', async () => {
|
|
const actor: TestUser = { user_id: 'u-1', user_type: 'internal' };
|
|
|
|
hasPermissionMock.mockImplementation(async (_user: unknown, resource: string, action: string) => {
|
|
if (resource !== 'timesheet') return false;
|
|
if (action === 'approve') return true;
|
|
if (action === 'read_all') return true;
|
|
return false;
|
|
});
|
|
|
|
resolveBundleRulesMock.mockResolvedValueOnce([
|
|
{
|
|
resource: 'time_entry',
|
|
action: 'read',
|
|
templateKey: 'own',
|
|
},
|
|
]);
|
|
|
|
await expect(assertCanActOnBehalf(actor as any, 'tenant-1', 'u-2', buildDb(['u-2']))).rejects.toThrow(
|
|
'Permission denied: Cannot access other users time sheets'
|
|
);
|
|
|
|
hasPermissionMock.mockImplementation(async (_user: unknown, resource: string, action: string) => {
|
|
if (resource !== 'timesheet') return false;
|
|
if (action === 'approve') return true;
|
|
if (action === 'read_all') return false;
|
|
return false;
|
|
});
|
|
|
|
resolveBundleRulesMock.mockResolvedValueOnce([
|
|
{
|
|
resource: 'time_entry',
|
|
action: 'read',
|
|
templateKey: 'own_or_managed',
|
|
},
|
|
]);
|
|
|
|
await expect(assertCanActOnBehalf(actor as any, 'tenant-1', 'u-999', buildDb([]))).rejects.toThrow(
|
|
'Permission denied: Cannot access other users time sheets'
|
|
);
|
|
});
|
|
|
|
it('T019: self-approval is allowed by default and denied only by configured not-self-approver bundle rules', async () => {
|
|
const actor: TestUser = { user_id: 'u-1', user_type: 'internal' };
|
|
|
|
hasPermissionMock.mockImplementation(async (_user: unknown, resource: string, action: string) => {
|
|
if (resource !== 'timesheet') return false;
|
|
if (action === 'approve') return true;
|
|
if (action === 'read_all') return true;
|
|
return false;
|
|
});
|
|
|
|
await expect(assertCanApproveSubject(actor as any, 'tenant-1', 'u-1', buildDb([]))).resolves.toBe('self');
|
|
|
|
resolveBundleRulesMock.mockResolvedValueOnce([
|
|
{
|
|
resource: 'time_entry',
|
|
action: 'approve',
|
|
constraintKey: 'not_self_approver',
|
|
},
|
|
]);
|
|
|
|
await expect(assertCanApproveSubject(actor as any, 'tenant-1', 'u-1', buildDb([]))).rejects.toThrow(
|
|
'Permission denied: Cannot approve your own time submissions'
|
|
);
|
|
|
|
await expect(assertCanApproveSubject(actor as any, 'tenant-1', 'u-2', buildDb(['u-2']))).resolves.toBe('tenant-wide');
|
|
});
|
|
|
|
it('T026: delegation and approval checks fail closed when bundle narrowing resolution errors', async () => {
|
|
const actor: TestUser = { user_id: 'u-1', user_type: 'internal' };
|
|
|
|
hasPermissionMock.mockImplementation(async (_user: unknown, resource: string, action: string) => {
|
|
if (resource !== 'timesheet') return false;
|
|
if (action === 'approve') return true;
|
|
if (action === 'read_all') return true;
|
|
return false;
|
|
});
|
|
|
|
resolveBundleRulesMock.mockRejectedValueOnce(new Error('bundle lookup failed'));
|
|
await expect(assertCanActOnBehalf(actor as any, 'tenant-1', 'u-2', buildDb(['u-2']))).rejects.toThrow(
|
|
'Permission denied: Cannot access other users time sheets'
|
|
);
|
|
|
|
resolveBundleRulesMock
|
|
.mockResolvedValueOnce([])
|
|
.mockRejectedValueOnce(new Error('bundle lookup failed'));
|
|
await expect(assertCanApproveSubject(actor as any, 'tenant-1', 'u-2', buildDb(['u-2']))).rejects.toThrow(
|
|
'Permission denied: Cannot approve time submissions'
|
|
);
|
|
});
|
|
});
|