Skip to content

Commit a05bbc5

Browse files
authored
Merge pull request #740 from SprocketBot/codex/union-org-team-dual-read
fix(core): union org-team permissions during dual-read
2 parents 47b2e1b + ad468a5 commit a05bbc5

3 files changed

Lines changed: 114 additions & 9 deletions

File tree

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
jest.mock("../../mledb/mledb-player/mledb-player.service", () => ({
2+
MledbPlayerService: jest.fn(),
3+
}));
4+
5+
import {MLE_OrganizationTeam} from "../../database/mledb/enums/OrganizationTeam.enum";
6+
import {OrgTeamPermissionResolutionService} from "./org-team-permission-resolution.service";
7+
8+
describe("OrgTeamPermissionResolutionService", () => {
9+
const originalDualRead = process.env.ORG_TEAM_PERMISSION_DUAL_READ;
10+
11+
const userOrgTeamPermissionService = {
12+
listOrgTeamsForUser: jest.fn(),
13+
};
14+
const mledbPlayerService = {
15+
getMlePlayerBySprocketUser: jest.fn(),
16+
getPlayerOrgs: jest.fn(),
17+
};
18+
19+
let service: OrgTeamPermissionResolutionService;
20+
21+
beforeEach(() => {
22+
jest.clearAllMocks();
23+
service = new OrgTeamPermissionResolutionService(
24+
userOrgTeamPermissionService as never,
25+
mledbPlayerService as never,
26+
);
27+
});
28+
29+
afterEach(() => {
30+
process.env.ORG_TEAM_PERMISSION_DUAL_READ = originalDualRead;
31+
});
32+
33+
it("returns Sprocket permissions without consulting MLEDB when dual-read is disabled", async () => {
34+
process.env.ORG_TEAM_PERMISSION_DUAL_READ = "false";
35+
userOrgTeamPermissionService.listOrgTeamsForUser.mockResolvedValue([
36+
MLE_OrganizationTeam.COORDINATOR,
37+
]);
38+
39+
const result = await service.resolveOrgTeamsForUser(42);
40+
41+
expect(result).toEqual([MLE_OrganizationTeam.COORDINATOR]);
42+
expect(mledbPlayerService.getMlePlayerBySprocketUser).not.toHaveBeenCalled();
43+
expect(mledbPlayerService.getPlayerOrgs).not.toHaveBeenCalled();
44+
});
45+
46+
it("returns the union of Sprocket and legacy permissions while dual-read is enabled", async () => {
47+
process.env.ORG_TEAM_PERMISSION_DUAL_READ = "true";
48+
userOrgTeamPermissionService.listOrgTeamsForUser.mockResolvedValue([
49+
MLE_OrganizationTeam.COORDINATOR,
50+
]);
51+
mledbPlayerService.getMlePlayerBySprocketUser.mockResolvedValue({id: 7});
52+
mledbPlayerService.getPlayerOrgs.mockResolvedValue([
53+
{orgTeam: MLE_OrganizationTeam.LEAGUE_OPERATIONS},
54+
{orgTeam: MLE_OrganizationTeam.COORDINATOR},
55+
]);
56+
57+
const result = await service.resolveOrgTeamsForUser(42);
58+
59+
expect(result).toEqual([
60+
MLE_OrganizationTeam.COORDINATOR,
61+
MLE_OrganizationTeam.LEAGUE_OPERATIONS,
62+
]);
63+
});
64+
65+
it("keeps Sprocket permissions when the legacy lookup fails during dual-read", async () => {
66+
process.env.ORG_TEAM_PERMISSION_DUAL_READ = "true";
67+
userOrgTeamPermissionService.listOrgTeamsForUser.mockResolvedValue([
68+
MLE_OrganizationTeam.LEAGUE_OPERATIONS,
69+
]);
70+
mledbPlayerService.getMlePlayerBySprocketUser.mockRejectedValue(new Error("no linked MLE player"));
71+
72+
const result = await service.resolveOrgTeamsForUser(42);
73+
74+
expect(result).toEqual([MLE_OrganizationTeam.LEAGUE_OPERATIONS]);
75+
});
76+
});

core/src/identity/user-org-team-permission/org-team-permission-resolution.service.ts

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,8 @@ import {UserOrgTeamPermissionService} from "./user-org-team-permission.service";
1111
*
1212
* **Temporary dual-read:** When `ORG_TEAM_PERMISSION_DUAL_READ=true`, always loads legacy
1313
* `mledb.player_to_org` as well, compares the two sets, and logs on mismatch. Effective permissions
14-
* still prefer Sprocket when it has rows; otherwise MLEDB is used only under dual-read (unbackfilled
15-
* users). Remove the env var and this branch once migration is validated.
14+
* are the union of Sprocket and MLEDB during the migration window so partially backfilled users do
15+
* not lose legacy access. Remove the env var and this branch once migration is validated.
1616
*/
1717
@Injectable()
1818
export class OrgTeamPermissionResolutionService {
@@ -34,6 +34,10 @@ export class OrgTeamPermissionResolutionService {
3434
return [...new Set(teams)].sort((x, y) => x - y).join(",");
3535
}
3636

37+
private combineOrgTeams(...teamSets: MLE_OrganizationTeam[][]): MLE_OrganizationTeam[] {
38+
return [...new Set(teamSets.flat())];
39+
}
40+
3741
async resolveOrgTeamsForUser(userId: number): Promise<MLE_OrganizationTeam[]> {
3842
const fromSprocket = await this.userOrgTeamPermissionService.listOrgTeamsForUser(userId);
3943
const dualRead = process.env.ORG_TEAM_PERMISSION_DUAL_READ === "true";
@@ -67,12 +71,9 @@ export class OrgTeamPermissionResolutionService {
6771
}
6872
}
6973

70-
if (fromSprocket.length > 0) {
71-
return fromSprocket;
72-
}
73-
if (dualRead && fromMledb.length > 0) {
74-
return fromMledb;
74+
if (dualRead) {
75+
return this.combineOrgTeams(fromSprocket, fromMledb);
7576
}
76-
return [];
77+
return fromSprocket;
7778
}
7879
}

reports/issue-726-org-team-permissions.md

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,40 @@ Admin GraphQL (MLEDB admin guard): `userOrgTeamPermissions`, `setUserOrgTeamPerm
1515

1616
When **`ORG_TEAM_PERMISSION_DUAL_READ=true`**, every resolution loads **both** Sprocket (`user_org_team_permission`) and legacy MLEDB (`player_to_org` via the linked player) and **compares** the two sets. Mismatches are logged (`warn` when both sides have data but differ, or Sprocket has rows while MLEDB is empty; `verbose` when Sprocket is empty but MLEDB has rows—common until backfill).
1717

18-
**Effective permissions:** if Sprocket has any rows for the user, those are returned; otherwise, under dual-read only, MLEDB’s set is returned so unbackfilled users still authorize.
18+
**Effective permissions:** while dual-read remains enabled, runtime authorization uses the **union** of Sprocket and MLEDB org-team rows. This keeps partially backfilled users from losing legacy permissions like `LEAGUE_OPERATIONS` during the migration window. Once dual-read is disabled, effective permissions are Sprocket-only.
19+
20+
**Risk:** dual-read union still trusts legacy `mledb.player_to_org`. That is intentional only during the migration window; stale legacy rows can continue granting access until the flag is disabled.
1921

2022
**Prod rollout (Pulumi):** the `platform` stack sets `ORG_TEAM_PERMISSION_DUAL_READ` on the core (and monolith) Docker service from Pulumi config key **`org-team-permission-dual-read`**. The committed `infra/platform/Pulumi.prod.yaml` sets it to **`true`** until backfill is done; flip it to **`false`** and `pulumi up` after validation.
2123

2224
**Backfill:** run `scripts/sql/backfill-user-org-team-permission-from-mledb.sql` against prod Postgres (same DB as `mledb` + `sprocket`).
2325

26+
**Audit before leaving dual-read enabled:**
27+
28+
```sql
29+
WITH legacy AS (
30+
SELECT uaa."userId", pto.org_team
31+
FROM mledb.player_to_org pto
32+
INNER JOIN mledb.player p ON p.id = pto.player_id
33+
INNER JOIN sprocket.user_authentication_account uaa
34+
ON uaa."accountId" = p.discord_id
35+
AND uaa."accountType" = 'DISCORD'
36+
),
37+
sprocket_rows AS (
38+
SELECT "userId", "orgTeam" AS org_team
39+
FROM sprocket.user_org_team_permission
40+
)
41+
SELECT legacy."userId", legacy.org_team
42+
FROM legacy
43+
LEFT JOIN sprocket_rows sr
44+
ON sr."userId" = legacy."userId"
45+
AND sr.org_team = legacy.org_team
46+
WHERE sr."userId" IS NULL
47+
ORDER BY legacy."userId", legacy.org_team;
48+
```
49+
50+
This query should return only expected temporary gaps immediately after backfill. Any unexpected rows are still legacy-only grants and should be reconciled before disabling dual-read.
51+
2452
**Removal plan:** After backfilling Sprocket permissions for all users who still rely on MLEDB (or after MLEDB is fully retired for auth), set `org-team-permission-dual-read` / `ORG_TEAM_PERMISSION_DUAL_READ` to `false`, confirm no regressions, then delete the fallback branch in `OrgTeamPermissionResolutionService` and any ops docs referencing the flag.
2553

2654
## Migration

0 commit comments

Comments
 (0)