Skip to content
Open
Show file tree
Hide file tree
Changes from 7 commits
Commits
Show all changes
14 commits
Select commit Hold shift + click to select a range
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
File renamed without changes.
33 changes: 31 additions & 2 deletions backend/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,9 @@ import { PassportModule } from '@nestjs/passport';
import { ScheduleModule } from '@nestjs/schedule';
import { TypeOrmModule } from '@nestjs/typeorm';
import { PrometheusModule } from '@willsoto/nestjs-prometheus';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { PostgresConnectionOptions } from 'typeorm/driver/postgres/PostgresConnectionOptions';
import accessConfig from './access_config.json';
import { appVersion } from './app-version';
import { AccessModule } from './endpoints/access/access.module';
import { ActionModule } from './endpoints/action/action.module';
Expand Down Expand Up @@ -48,7 +49,35 @@ import { DBDumper } from './services/dbdumper.service';
configuration,
(): {
accessConfig: AccessGroupConfig;
} => ({ accessConfig: accessConfig as AccessGroupConfig }),
} => {
const configPath =
process.env.ACCESS_CONFIG_PATH ??
path.resolve(process.cwd(), '..', 'access_config.json');
const accessConfig = JSON.parse(
fs.readFileSync(configPath, 'utf8'),
) as AccessGroupConfig;
if (
!Array.isArray(accessConfig.emails) ||
!Array.isArray(accessConfig.access_groups)
) {
throw new TypeError(
`Invalid access_config.json: "emails" and "access_groups" must be arrays`,
);
}
const configGroupUuids = new Set(
accessConfig.access_groups.map((g) => g.uuid),
);
for (const emailEntry of accessConfig.emails) {
for (const uuid of emailEntry.access_groups) {
if (!configGroupUuids.has(uuid)) {
throw new TypeError(
`Invalid access_config.json: UUID "${uuid}" in emails config is not defined in access_groups`,
);
}
}
}
return { accessConfig };
},
],
}),
TypeOrmModule.forRootAsync({
Expand Down
5 changes: 4 additions & 1 deletion backend/src/services/auth.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,10 @@ export class AuthService implements OnModuleInit {
}

async onModuleInit(): Promise<void> {
await this.affiliationGroupService.createAccessGroups(this.config);
await this.affiliationGroupService.syncAccessGroups(
this.config,
this.userRepository,
);
Comment on lines +55 to +58

Copilot AI Apr 14, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AuthService.onModuleInit() now calls syncAccessGroups(), which performs potentially heavy DB writes/reads across all users. This makes application startup time/data-plane health dependent on completing a full sync. If this is intended, consider moving to a dedicated bootstrap job with logging/metrics and (at minimum) ensuring it is resilient to partial failures (e.g., wrap and log errors rather than failing module init).

Suggested change
await this.affiliationGroupService.syncAccessGroups(
this.config,
this.userRepository,
);
logger.info('Starting access group sync in background during module initialization');
void this.affiliationGroupService
.syncAccessGroups(this.config, this.userRepository)
.then(() => {
logger.info('Access group sync completed');
})
.catch((error: unknown) => {
logger.error(
'Access group sync failed during module initialization',
error,
);
});

Copilot uses AI. Check for mistakes.
Comment on lines 54 to +58

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 createAccessGroups is redundant before syncAccessGroups

syncAccessGroups already upserts every group from the config in its step 1, so the preceding createAccessGroups call performs duplicate lookups for every configured group. Removing it avoids the unnecessary round-trips.

Prompt To Fix With AI
This is a comment left during a code review.
Path: backend/src/services/auth.service.ts
Line: 54-59

Comment:
**`createAccessGroups` is redundant before `syncAccessGroups`**

`syncAccessGroups` already upserts every group from the config in its step 1, so the preceding `createAccessGroups` call performs duplicate lookups for every configured group. Removing it avoids the unnecessary round-trips.

How can I resolve this? If you propose a fix, please make it concise.

}

// eslint-disable-next-line @typescript-eslint/no-explicit-any
Expand Down
333 changes: 333 additions & 0 deletions backend/tests/auth/access-groups/affiliation-sync-on-reboot.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,333 @@
import { createNewUser } from '@/services/auth.service';
import {
AccessGroupEntity,
AccountEntity,
AffiliationGroupService,
GroupMembershipEntity,
UserEntity,
} from '@kleinkram/backend-common';
import {
AccessGroupConfig,
AccessGroupType,
Providers,
} from '@kleinkram/shared';
import { database } from '../../utils/database-utilities';
import { setupDatabaseHooks } from '../../utils/test-helpers';

describe('Affiliation Group Sync on Reboot', () => {
setupDatabaseHooks();

const GROUP_A_UUID = '00000000-aaaa-aaaa-aaaa-aaaaaaaaaaaa';
const GROUP_B_UUID = '00000000-bbbb-bbbb-bbbb-bbbbbbbbbbbb';

let affiliationGroupService: AffiliationGroupService;

beforeAll(() => {
const accessGroupRepository = database.getRepository(AccessGroupEntity);
const groupMembershipRepository = database.getRepository(
GroupMembershipEntity,
);
affiliationGroupService = new AffiliationGroupService(
accessGroupRepository,
groupMembershipRepository,
);
});

const createUser = async (email: string, config: AccessGroupConfig) => {
const userRepository = database.getRepository(UserEntity);
const accountRepository = database.getRepository(AccountEntity);
await createNewUser(
config,
userRepository,
accountRepository,
affiliationGroupService,
{
oauthID: `oauth-${email}`,
provider: Providers.FakeOAuth,
email,
username: email.split('@')[0],
picture: '',
},
);
};

test('should add users to a new affiliation group on sync', async () => {
const userRepository = database.getRepository(UserEntity);

const initialConfig: AccessGroupConfig = {
emails: [{ email: 'kleinkram.dev', access_groups: [GROUP_A_UUID] }],

Check warning on line 58 in backend/tests/auth/access-groups/affiliation-sync-on-reboot.test.ts

View workflow job for this annotation

GitHub Actions / eslint

Object Literal Property name `access_groups` must match one of the following formats: camelCase, PascalCase, UPPER_CASE
access_groups: [

Check warning on line 59 in backend/tests/auth/access-groups/affiliation-sync-on-reboot.test.ts

View workflow job for this annotation

GitHub Actions / eslint

Object Literal Property name `access_groups` must match one of the following formats: camelCase, PascalCase, UPPER_CASE
{ name: 'Group A', uuid: GROUP_A_UUID, rights: 10 },
],
};

await affiliationGroupService.createAccessGroups(initialConfig);
await createUser('alice@kleinkram.dev', initialConfig);

// Now add Group B to config and sync
const updatedConfig: AccessGroupConfig = {
emails: [
{
email: 'kleinkram.dev',
access_groups: [GROUP_A_UUID, GROUP_B_UUID],

Check warning on line 72 in backend/tests/auth/access-groups/affiliation-sync-on-reboot.test.ts

View workflow job for this annotation

GitHub Actions / eslint

Object Literal Property name `access_groups` must match one of the following formats: camelCase, PascalCase, UPPER_CASE
},
],
access_groups: [

Check warning on line 75 in backend/tests/auth/access-groups/affiliation-sync-on-reboot.test.ts

View workflow job for this annotation

GitHub Actions / eslint

Object Literal Property name `access_groups` must match one of the following formats: camelCase, PascalCase, UPPER_CASE
{ name: 'Group A', uuid: GROUP_A_UUID, rights: 10 },
{ name: 'Group B', uuid: GROUP_B_UUID, rights: 5 },
],
};

await affiliationGroupService.syncAccessGroups(
updatedConfig,
userRepository,
);

const user = await userRepository.findOneOrFail({
where: { email: 'alice@kleinkram.dev' },
relations: ['memberships', 'memberships.accessGroup'],
});

const groupUuids =
user.memberships?.map((m) => m.accessGroup?.uuid) ?? [];
expect(groupUuids).toContain(GROUP_A_UUID);
expect(groupUuids).toContain(GROUP_B_UUID);
});

test('should remove memberships when group is removed from config', async () => {
const userRepository = database.getRepository(UserEntity);

const initialConfig: AccessGroupConfig = {
emails: [
{
email: 'kleinkram.dev',
access_groups: [GROUP_A_UUID, GROUP_B_UUID],

Check warning on line 104 in backend/tests/auth/access-groups/affiliation-sync-on-reboot.test.ts

View workflow job for this annotation

GitHub Actions / eslint

Object Literal Property name `access_groups` must match one of the following formats: camelCase, PascalCase, UPPER_CASE
},
],
access_groups: [

Check warning on line 107 in backend/tests/auth/access-groups/affiliation-sync-on-reboot.test.ts

View workflow job for this annotation

GitHub Actions / eslint

Object Literal Property name `access_groups` must match one of the following formats: camelCase, PascalCase, UPPER_CASE
{ name: 'Group A', uuid: GROUP_A_UUID, rights: 10 },
{ name: 'Group B', uuid: GROUP_B_UUID, rights: 5 },
],
};

await affiliationGroupService.createAccessGroups(initialConfig);
await createUser('bob@kleinkram.dev', initialConfig);

// Remove Group B from config
const updatedConfig: AccessGroupConfig = {
emails: [{ email: 'kleinkram.dev', access_groups: [GROUP_A_UUID] }],

Check warning on line 118 in backend/tests/auth/access-groups/affiliation-sync-on-reboot.test.ts

View workflow job for this annotation

GitHub Actions / eslint

Object Literal Property name `access_groups` must match one of the following formats: camelCase, PascalCase, UPPER_CASE
access_groups: [

Check warning on line 119 in backend/tests/auth/access-groups/affiliation-sync-on-reboot.test.ts

View workflow job for this annotation

GitHub Actions / eslint

Object Literal Property name `access_groups` must match one of the following formats: camelCase, PascalCase, UPPER_CASE
{ name: 'Group A', uuid: GROUP_A_UUID, rights: 10 },
],
};

await affiliationGroupService.syncAccessGroups(
updatedConfig,
userRepository,
);

const user = await userRepository.findOneOrFail({
where: { email: 'bob@kleinkram.dev' },
relations: ['memberships', 'memberships.accessGroup'],
});

const groupUuids =
user.memberships?.map((m) => m.accessGroup?.uuid) ?? [];
expect(groupUuids).toContain(GROUP_A_UUID);
expect(groupUuids).not.toContain(GROUP_B_UUID);

// Group B should be soft-deleted
const accessGroupRepository = database.getRepository(AccessGroupEntity);
const groupB = await accessGroupRepository.findOne({
where: { uuid: GROUP_B_UUID },
});
expect(groupB).toBeNull();
});

test('should update group name when changed in config', async () => {
const userRepository = database.getRepository(UserEntity);

const initialConfig: AccessGroupConfig = {
emails: [{ email: 'kleinkram.dev', access_groups: [GROUP_A_UUID] }],
access_groups: [
{ name: 'Group A', uuid: GROUP_A_UUID, rights: 10 },
],
};

await affiliationGroupService.syncAccessGroups(
initialConfig,
userRepository,
);

const updatedConfig: AccessGroupConfig = {
emails: [{ email: 'kleinkram.dev', access_groups: [GROUP_A_UUID] }],
access_groups: [
{ name: 'Group A Renamed', uuid: GROUP_A_UUID, rights: 10 },
],
};

await affiliationGroupService.syncAccessGroups(
updatedConfig,
userRepository,
);

const accessGroupRepository = database.getRepository(AccessGroupEntity);
const group = await accessGroupRepository.findOneOrFail({
where: { uuid: GROUP_A_UUID },
});
expect(group.name).toBe('Group A Renamed');
});

test('should remove memberships when email pattern changes', async () => {
const userRepository = database.getRepository(UserEntity);

const initialConfig: AccessGroupConfig = {
emails: [{ email: 'kleinkram.dev', access_groups: [GROUP_A_UUID] }],
access_groups: [
{ name: 'Group A', uuid: GROUP_A_UUID, rights: 10 },
],
};

await affiliationGroupService.createAccessGroups(initialConfig);
await createUser('carol@kleinkram.dev', initialConfig);

// Change email pattern so carol no longer matches
const updatedConfig: AccessGroupConfig = {
emails: [
{ email: 'other-domain.com', access_groups: [GROUP_A_UUID] },
],
access_groups: [
{ name: 'Group A', uuid: GROUP_A_UUID, rights: 10 },
],
};

await affiliationGroupService.syncAccessGroups(
updatedConfig,
userRepository,
);

const user = await userRepository.findOneOrFail({
where: { email: 'carol@kleinkram.dev' },
relations: ['memberships', 'memberships.accessGroup'],
});

const affiliationMemberships = (user.memberships ?? []).filter(
(m) => m.accessGroup?.type === AccessGroupType.AFFILIATION,
);
expect(affiliationMemberships).toHaveLength(0);
});

test('should be idempotent when called twice with same config', async () => {
const userRepository = database.getRepository(UserEntity);

const config: AccessGroupConfig = {
emails: [{ email: 'kleinkram.dev', access_groups: [GROUP_A_UUID] }],
access_groups: [
{ name: 'Group A', uuid: GROUP_A_UUID, rights: 10 },
],
};

await affiliationGroupService.createAccessGroups(config);
await createUser('dave@kleinkram.dev', config);

await affiliationGroupService.syncAccessGroups(config, userRepository);
await affiliationGroupService.syncAccessGroups(config, userRepository);

const user = await userRepository.findOneOrFail({
where: { email: 'dave@kleinkram.dev' },
relations: ['memberships', 'memberships.accessGroup'],
});

const affiliationMemberships = (user.memberships ?? []).filter(
(m) => m.accessGroup?.type === AccessGroupType.AFFILIATION,
);
expect(affiliationMemberships).toHaveLength(1);
expect(affiliationMemberships[0].accessGroup?.uuid).toBe(GROUP_A_UUID);
});

test('should not touch primary or custom groups', async () => {
const userRepository = database.getRepository(UserEntity);

const config: AccessGroupConfig = {
emails: [{ email: 'kleinkram.dev', access_groups: [GROUP_A_UUID] }],
access_groups: [
{ name: 'Group A', uuid: GROUP_A_UUID, rights: 10 },
],
};

await affiliationGroupService.createAccessGroups(config);
await createUser('eve@kleinkram.dev', config);

// Verify user has a primary group
const userBefore = await userRepository.findOneOrFail({
where: { email: 'eve@kleinkram.dev' },
relations: ['memberships', 'memberships.accessGroup'],
});
const primaryBefore = (userBefore.memberships ?? []).filter(
(m) => m.accessGroup?.type === AccessGroupType.PRIMARY,
);
expect(primaryBefore.length).toBeGreaterThan(0);

// Sync with empty config (no affiliation groups)
const emptyConfig: AccessGroupConfig = {
emails: [],
access_groups: [],
};

await affiliationGroupService.syncAccessGroups(
emptyConfig,
userRepository,
);

// Primary group should still exist
const userAfter = await userRepository.findOneOrFail({
where: { email: 'eve@kleinkram.dev' },
relations: ['memberships', 'memberships.accessGroup'],
});
const primaryAfter = (userAfter.memberships ?? []).filter(
(m) => m.accessGroup?.type === AccessGroupType.PRIMARY,
);
expect(primaryAfter).toHaveLength(primaryBefore.length);
});

test('should remove manually-added affiliation membership when user does not match pattern', async () => {
const userRepository = database.getRepository(UserEntity);
const groupMembershipRepository = database.getRepository(
GroupMembershipEntity,
);

const config: AccessGroupConfig = {
emails: [{ email: 'kleinkram.dev', access_groups: [GROUP_A_UUID] }],
access_groups: [
{ name: 'Group A', uuid: GROUP_A_UUID, rights: 10 },
],
};

await affiliationGroupService.createAccessGroups(config);

// Create an external user (non-matching email)
await createUser('frank@external.com', config);

// Manually add the external user to the affiliation group
const frank = await userRepository.findOneOrFail({
where: { email: 'frank@external.com' },
});
const manualMembership = groupMembershipRepository.create({
user: { uuid: frank.uuid },
accessGroup: { uuid: GROUP_A_UUID },
});
await groupMembershipRepository.save(manualMembership);

// Sync should remove the manual membership
await affiliationGroupService.syncAccessGroups(config, userRepository);

const updatedFrank = await userRepository.findOneOrFail({
where: { email: 'frank@external.com' },
relations: ['memberships', 'memberships.accessGroup'],
});
const affiliationMemberships = (updatedFrank.memberships ?? []).filter(
(m) => m.accessGroup?.type === AccessGroupType.AFFILIATION,
);
expect(affiliationMemberships).toHaveLength(0);
});
});
Loading
Loading