Skip to content
Open
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
39 changes: 27 additions & 12 deletions .snyk
Original file line number Diff line number Diff line change
@@ -1,51 +1,66 @@
version: v1.5.0
ignore:
'SNYK-JS-JSYAML-18313070':
- '* > js-yaml':
reason: 'Transitive via gray-matter (Docusaurus), which pins js-yaml 3.x and calls yaml.safeLoad — removed in js-yaml 4, so the 4.x-only fix breaks the docs build. Build-time parsing of repository-controlled input only.'
expires: '2026-10-31T00:00:00.000Z'
created: '2026-07-28T00:00:00.000Z'
'SNYK-JS-REACTROUTER-18313151':
- '* > react-router':
reason: 'RSC-mode CSRF; fix only in react-router v8. Our apps are SPA BrowserRouter clients that do not use RSC server actions, so the vulnerable code path is unreachable. Mirrors GHSA-qwww-vcr4-c8h2 ignored in pnpm audit.'
expires: '2026-10-31T00:00:00.000Z'
created: '2026-07-28T00:00:00.000Z'
'SNYK-JS-IPADDRESS-18343249':
- '* > ip-address':
reason: 'SSRF in ip-address@10.2.0 with no upstream fix; transitive via express-rate-limit in @cellix/server-oauth2-mock-seedwork, a dev-only mock OAuth server never deployed to production.'
expires: '2026-10-31T00:00:00.000Z'
created: '2026-07-28T00:00:00.000Z'
'SNYK-JS-SIRV-12558119':
- '* > sirv@2.0.4':
reason: 'Transitive dependency in Docusaurus; not exploitable in static site serving context (dev-only asset handler)'
expires: '2026-07-28T00:00:00.000Z'
expires: '2026-10-31T00:00:00.000Z'
created: '2025-11-06T15:57:00.000Z'
'SNYK-JS-JSYAML-13961110':
- '* > js-yaml':
reason: 'Transitive dependency in Docusaurus; upgrade path blocked until https://github.com/jonschlinkert/gray-matter/pull/137#issuecomment-3533768351 is merged and transitive deps are updated. Not exploitable in current usage.'
expires: '2026-07-28T00:00:00.000Z'
expires: '2026-10-31T00:00:00.000Z'
created: '2025-11-17T09:06:00.000Z'
'SNYK-JS-NODEFORGE-14114940':
- '* > node-forge':
reason: 'Transitive dependency in Docusaurus; not exploitable in current usage.'
expires: '2026-07-28T00:00:00.000Z'
expires: '2026-10-31T00:00:00.000Z'
created: '2025-11-26T10:12:00.000Z'
'SNYK-JS-EXPRESS-14157151':
- '@docusaurus/core@3.9.2 > * > express':
reason: 'Transitive dependency in Docusaurus; not exploitable in current usage.'
expires: '2026-07-28T00:00:00.000Z'
expires: '2026-10-31T00:00:00.000Z'
created: '2025-12-02T09:39:00.000Z'
- '@docusaurus/plugin-content-docs@3.9.2 > * > express':
reason: 'Transitive dependency in Docusaurus; not exploitable in current usage.'
expires: '2026-07-28T00:00:00.000Z'
expires: '2026-10-31T00:00:00.000Z'
created: '2025-12-02T09:39:00.000Z'
- '@docusaurus/preset-classic@3.9.2 > * > express':
reason: 'Transitive dependency in Docusaurus; not exploitable in current usage.'
expires: '2026-07-28T00:00:00.000Z'
expires: '2026-10-31T00:00:00.000Z'
created: '2025-12-02T09:39:00.000Z'
'SNYK-JS-QS-14724253':
- '* > qs@6.14.0':
reason: 'Transitive dependency in @apollo/server, body-parser; not exploitable in current usage.'
expires: '2026-07-28T00:00:00.000Z'
expires: '2026-10-31T00:00:00.000Z'
created: '2026-01-28T00:00:00.000Z'
- '* > qs@6.13.0':
reason: 'Transitive dependency in express, @docusaurus/core; not exploitable in current usage.'
expires: '2026-07-28T00:00:00.000Z'
expires: '2026-10-31T00:00:00.000Z'
created: '2026-01-28T00:00:00.000Z'
'SNYK-JS-PNPMNPMCONF-14897556':
- '* > @pnpm/npm-conf@2.3.1':
reason: 'Transitive dependency in @docusaurus/core; not exploitable in current usage.'
expires: '2026-07-28T00:00:00.000Z'
expires: '2026-10-31T00:00:00.000Z'
created: '2026-01-08T11:04:00.000Z'
'SNYK-JS-UNDICI-14943963':
- '* > undici':
reason: 'Transitive dependency in @azure/functions; upgrade path has type errors'
expires: '2026-07-28T00:00:00.000Z'
expires: '2026-10-31T00:00:00.000Z'
created: '2026-01-15T11:04:00.000Z'
'SNYK-JS-YAUZL-15467445':
- '* > yauzl@<3.2.1':
Expand Down Expand Up @@ -94,7 +109,7 @@ ignore:
'SNYK-JS-OPENTELEMETRYEXPORTERPROMETHEUS-16758050':
- '* > @opentelemetry/exporter-prometheus@0.57.2':
reason: 'Requires upgrade of @opentelemetry/sdk-node to 0.217.0, which has type errors that break compilation. Created task to upgrade OTEL service to 2.x and resolve vulnerability that way.'
expires: '2026-07-28T00:00:00.000Z'
expires: '2026-10-31T00:00:00.000Z'
created: '2026-06-01T10:00:00.000Z'
'SNYK-JS-POSTCSSSELECTORPARSER-16873882':
- '* > postcss-selector-parser':
Expand Down Expand Up @@ -162,7 +177,7 @@ ignore:
'SNYK-JS-OPENTELEMETRYCORE-17373280':
- '* > @opentelemetry/core':
reason: 'The fix requires migrating the Azure Functions telemetry stack from OpenTelemetry 1.x to 2.8.0 or newer. The current Azure exporter and 0.57.x SDK packages require the 1.x API family; a forced major override is type-compatible at build time but leaves mixed SDK internals. Accepted temporarily while the existing OTEL 2.x migration is completed.'
expires: '2026-07-18T00:00:00.000Z'
expires: '2026-10-31T00:00:00.000Z'
created: '2026-06-18T00:00:00.000Z'
'SNYK-JS-BRACEEXPANSION-17706650':
- '* > brace-expansion@1.1.13':
Expand Down
33 changes: 33 additions & 0 deletions apps/api/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ const {
startUp,
initializeInfrastructureServices,
registerEventHandlers,
recoverDeletedStaffRoles,
registerTimer,
MockServiceApolloServer,
MockServiceClientBlobStorage,
MockServiceBlobStorage,
Expand Down Expand Up @@ -66,6 +68,8 @@ const {
startUp: vi.fn(),
initializeInfrastructureServices: vi.fn(),
registerEventHandlers: vi.fn(),
recoverDeletedStaffRoles: vi.fn(() => Promise.resolve(0)),
registerTimer: vi.fn(),
MockServiceApolloServer: HoistedServiceApolloServer,
MockServiceClientBlobStorage: HoistedServiceClientBlobStorage,
MockServiceBlobStorage: HoistedServiceBlobStorage,
Expand All @@ -85,6 +89,11 @@ const serviceRegistry = {
};

vi.mock('./service-config/otel-starter.ts', () => ({}));
vi.mock('@azure/functions', () => ({
app: {
timer: registerTimer,
},
}));
vi.mock('./cellix.ts', () => ({
Cellix: {
initializeInfrastructureServices,
Expand All @@ -108,6 +117,7 @@ vi.mock('@ocom/application-services', () => ({
}));
vi.mock('@ocom/event-handler', () => ({
RegisterEventHandlers: registerEventHandlers,
RecoverDeletedStaffRoles: recoverDeletedStaffRoles,
}));
vi.mock('./service-config/mongoose/index.ts', () => ({
mongooseConnectionString: 'mongodb://example.test/cellix',
Expand Down Expand Up @@ -254,6 +264,29 @@ describe('apps/api bootstrap', () => {
apolloServerService: { service: 'apollo' },
});
expect(registerEventHandlers).toHaveBeenCalledWith({ domain: 'data-source' });
expect(recoverDeletedStaffRoles).toHaveBeenCalledWith({ domain: 'data-source' });
expect(registerTimer).toHaveBeenCalledWith(
'staffRoleDeletionRecovery',
expect.objectContaining({
schedule: '0 * * * * *',
useMonitor: true,
retry: {
strategy: 'fixedDelay',
maxRetryCount: 3,
delayInterval: 5000,
},
}),
);
const timerOptions = registerTimer.mock.calls[0]?.[1] as
| {
handler: () => Promise<void>;
}
| undefined;
await timerOptions?.handler();
expect(recoverDeletedStaffRoles).toHaveBeenCalledTimes(2);
const timerFailure = new Error('recovery failed');
recoverDeletedStaffRoles.mockRejectedValueOnce(timerFailure);
await expect(timerOptions?.handler()).rejects.toBe(timerFailure);
});

it('registers client-signing blob storage for backend use outside production', async () => {
Expand Down
24 changes: 23 additions & 1 deletion apps/api/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import './service-config/otel-starter.ts';

import { app } from '@azure/functions';
import { type ApplicationServices, buildApplicationServicesFactory } from '@ocom/application-services';
import type { ApiContextSpec } from '@ocom/context-spec';
import { RegisterEventHandlers } from '@ocom/event-handler';
import { RecoverDeletedStaffRoles, RegisterEventHandlers } from '@ocom/event-handler';
import { type GraphContext, graphHandlerCreator } from '@ocom/graphql-handler';
import { restHandlerCreator } from '@ocom/rest';
import { ServiceApolloServer } from '@ocom/service-apollo-server';
Expand All @@ -19,6 +20,7 @@ import * as TokenValidationConfig from './service-config/token-validation/index.

const { NODE_ENV } = process.env;
const isProd = NODE_ENV === 'production';
let recoverDeletedStaffRolesForHost: (() => Promise<number>) | undefined;

Cellix.initializeInfrastructureServices<ApiContextSpec, ApplicationServices>((serviceRegistry) => {
serviceRegistry
Expand Down Expand Up @@ -53,6 +55,10 @@ Cellix.initializeInfrastructureServices<ApiContextSpec, ApplicationServices>((se

const { domainDataSource } = dataSourcesFactory.withSystemPassport();
RegisterEventHandlers(domainDataSource);
recoverDeletedStaffRolesForHost = () => RecoverDeletedStaffRoles(domainDataSource);
void recoverDeletedStaffRolesForHost().catch((error: unknown) => {
console.error('Failed to recover deleted staff role events during application startup', error);
});

return {
dataSourcesFactory,
Expand All @@ -69,3 +75,19 @@ Cellix.initializeInfrastructureServices<ApiContextSpec, ApplicationServices>((se
)
.registerAzureFunctionHttpHandler('rest', { route: '{communityId}/{role}/{memberId}/{*rest}' }, restHandlerCreator)
.startUp();

app.timer('staffRoleDeletionRecovery', {
schedule: '0 * * * * *',
useMonitor: true,
retry: {
strategy: 'fixedDelay',
maxRetryCount: 3,
delayInterval: 5000,
},
handler: async () => {
if (!recoverDeletedStaffRolesForHost) {
throw new Error('Staff role deletion recovery is unavailable before application startup completes');
}
await recoverDeletedStaffRolesForHost();
},
});
12 changes: 6 additions & 6 deletions apps/server-mongodb-memory-mock/src/seed/seed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,30 +32,30 @@ async function upsertSeedDocuments(connection: Connection, collectionName: strin
export async function seedDatabase(connection: Connection): Promise<void> {
const users = endUsers.map((u: EndUser) => ({
...u,
_id: toObjectId(u._id as string),
_id: toObjectId(String(u._id)),
}));
await upsertSeedDocuments(connection, 'users', users);
console.log(` Seeded ${users.length} users`);

const comms = communities.map((c: Community) => ({
...c,
_id: toObjectId(c._id as string),
_id: toObjectId(String(c._id)),
createdBy: toObjectId(String(c.createdBy)),
}));
await upsertSeedDocuments(connection, 'communities', comms);
console.log(` Seeded ${comms.length} communities`);

const roles = endUserRoles.map((r: EndUserRole) => ({
...r,
_id: toObjectId(r._id as string),
_id: toObjectId(String(r._id)),
community: toObjectId(String(r.community)),
}));
await upsertSeedDocuments(connection, 'roles', roles);
console.log(` Seeded ${roles.length} roles`);

const mems = members.map((m: Member) => ({
...m,
_id: toObjectId(m._id as string),
_id: toObjectId(String(m._id)),
community: toObjectId(String(m.community)),
role: m.role ? toObjectId(String(m.role)) : undefined,
accounts: m.accounts.map((a) => ({
Expand All @@ -70,7 +70,7 @@ export async function seedDatabase(connection: Connection): Promise<void> {

const props = properties.map((p: Property) => ({
...p,
_id: toObjectId(p._id as string),
_id: toObjectId(String(p._id)),
community: toObjectId(String(p.community)),
owner: p.owner ? toObjectId(String(p.owner)) : undefined,
}));
Expand All @@ -79,7 +79,7 @@ export async function seedDatabase(connection: Connection): Promise<void> {

const svcs = services.map((s: Service) => ({
...s,
_id: toObjectId(s._id as string),
_id: toObjectId(String(s._id)),
community: toObjectId(String(s.community)),
}));
await upsertSeedDocuments(connection, 'services', svcs);
Expand Down
4 changes: 2 additions & 2 deletions apps/ui-staff/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,7 @@ export default function App() {
}

function StaffSection({ identity }: { identity: Parameters<typeof StaffAuthProvider>[0]['value'] }) {
const { permissions, enterpriseAppRole, user, loading } = useStaffPermissions();
const { permissions, enterpriseAppRole, currentRoleId, user, loading } = useStaffPermissions();

if (loading) {
return (
Expand All @@ -153,7 +153,7 @@ function StaffSection({ identity }: { identity: Parameters<typeof StaffAuthProvi
}

return (
<StaffAuthProvider value={{ ...identity, permissions, enterpriseAppRole, name: user?.displayName, email: user?.email }}>
<StaffAuthProvider value={{ ...identity, permissions, enterpriseAppRole, currentRoleId, name: user?.displayName, email: user?.email }}>
<StaffRoutes />
</StaffAuthProvider>
);
Expand Down
42 changes: 42 additions & 0 deletions apps/ui-staff/src/hooks/use-staff-permissions.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { describe, expect, it } from 'vitest';
import { deriveStaffPermissions } from './use-staff-permissions.ts';

const makeRolePermissions = (canRemoveRole: boolean) => ({
communityPermissions: {
canManageCommunities: false,
canManageStaffRolesAndPermissions: true,
},
userPermissions: {
canManageUsers: false,
canAssignStaffRoles: false,
canViewStaffUsers: false,
},
staffRolePermissions: {
canViewRoles: false,
canAddRole: false,
canEditRole: false,
canRemoveRole,
},
financePermissions: {
canManageFinance: false,
},
techAdminPermissions: {
canManageTechAdmin: true,
},
});

describe('deriveStaffPermissions', () => {
it('does not infer role removal from broader management permissions', () => {
const permissions = deriveStaffPermissions(makeRolePermissions(false));

expect(permissions?.canManageStaffRolesAndPermissions).toBe(true);
expect(permissions?.canEditRole).toBe(true);
expect(permissions?.canRemoveRole).toBe(false);
});

it('grants role removal when the explicit permission is present', () => {
const permissions = deriveStaffPermissions(makeRolePermissions(true));

expect(permissions?.canRemoveRole).toBe(true);
});
});
45 changes: 26 additions & 19 deletions apps/ui-staff/src/hooks/use-staff-permissions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,9 +78,33 @@ interface StaffUserQueryResult {
};
}

type RolePermissions = NonNullable<NonNullable<StaffUserQueryResult['currentStaffUserAndCreateIfNotExists']['role']>['permissions']>;

export const deriveStaffPermissions = (rolePermissions: RolePermissions | undefined): StaffPermissions | undefined => {
if (!rolePermissions) {
return undefined;
}

const isTechAdmin = rolePermissions.techAdminPermissions.canManageTechAdmin;
return {
canManageCommunities: rolePermissions.communityPermissions.canManageCommunities || isTechAdmin,
canManageStaffRolesAndPermissions: rolePermissions.communityPermissions.canManageStaffRolesAndPermissions || isTechAdmin,
canManageUsers: rolePermissions.userPermissions.canManageUsers || isTechAdmin,
canAssignStaffRoles: rolePermissions.userPermissions.canAssignStaffRoles || isTechAdmin,
canViewStaffUsers: rolePermissions.userPermissions.canViewStaffUsers || rolePermissions.userPermissions.canManageUsers || isTechAdmin,
canManageFinance: rolePermissions.financePermissions.canManageFinance || isTechAdmin,
canManageTechAdmin: isTechAdmin,
canViewRoles: rolePermissions.staffRolePermissions.canViewRoles || rolePermissions.communityPermissions.canManageStaffRolesAndPermissions || isTechAdmin,
canAddRole: rolePermissions.staffRolePermissions.canAddRole || rolePermissions.communityPermissions.canManageStaffRolesAndPermissions || isTechAdmin,
canEditRole: rolePermissions.staffRolePermissions.canEditRole || rolePermissions.communityPermissions.canManageStaffRolesAndPermissions || isTechAdmin,
canRemoveRole: rolePermissions.staffRolePermissions.canRemoveRole,
};
};

export const useStaffPermissions = (): {
permissions: StaffPermissions | undefined;
enterpriseAppRole: string | undefined;
currentRoleId: string | undefined;
user: { id?: string; displayName?: string; firstName?: string; lastName?: string; email?: string } | undefined;
loading: boolean;
error: Error | undefined;
Expand All @@ -91,29 +115,12 @@ export const useStaffPermissions = (): {

const rolePermissions = data?.currentStaffUserAndCreateIfNotExists?.role?.permissions;
const currentUser = data?.currentStaffUserAndCreateIfNotExists;

// Treat a TechAdmin as an implicit manager of all sections
const isTechAdmin = rolePermissions?.techAdminPermissions?.canManageTechAdmin ?? false;

const permissions: StaffPermissions | undefined = rolePermissions
? {
canManageCommunities: rolePermissions.communityPermissions.canManageCommunities || isTechAdmin,
canManageStaffRolesAndPermissions: rolePermissions.communityPermissions.canManageStaffRolesAndPermissions || isTechAdmin,
canManageUsers: rolePermissions.userPermissions.canManageUsers || isTechAdmin,
canAssignStaffRoles: rolePermissions.userPermissions.canAssignStaffRoles || isTechAdmin,
canViewStaffUsers: rolePermissions.userPermissions.canViewStaffUsers || rolePermissions.userPermissions.canManageUsers || isTechAdmin,
canManageFinance: rolePermissions.financePermissions.canManageFinance || isTechAdmin,
canManageTechAdmin: isTechAdmin,
canViewRoles: rolePermissions.staffRolePermissions.canViewRoles || rolePermissions.communityPermissions.canManageStaffRolesAndPermissions || isTechAdmin,
canAddRole: rolePermissions.staffRolePermissions.canAddRole || rolePermissions.communityPermissions.canManageStaffRolesAndPermissions || isTechAdmin,
canEditRole: rolePermissions.staffRolePermissions.canEditRole || rolePermissions.communityPermissions.canManageStaffRolesAndPermissions || isTechAdmin,
canRemoveRole: rolePermissions.staffRolePermissions.canRemoveRole || rolePermissions.communityPermissions.canManageStaffRolesAndPermissions || isTechAdmin,
}
: undefined;
const permissions = deriveStaffPermissions(rolePermissions);

return {
permissions,
enterpriseAppRole: data?.currentStaffUserAndCreateIfNotExists?.role?.enterpriseAppRole,
currentRoleId: data?.currentStaffUserAndCreateIfNotExists?.role?.id,
user: currentUser
? {
id: currentUser.id,
Expand Down
Loading
Loading