diff --git a/core/migrations/1770500000000-UserOrgTeamPermission.ts b/core/migrations/1770500000000-UserOrgTeamPermission.ts new file mode 100644 index 000000000..ab10d509d --- /dev/null +++ b/core/migrations/1770500000000-UserOrgTeamPermission.ts @@ -0,0 +1,33 @@ +import {MigrationInterface, QueryRunner} from "typeorm"; + +export class UserOrgTeamPermission1770500000000 implements MigrationInterface { + name = "UserOrgTeamPermission1770500000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + "CREATE TABLE \"sprocket\".\"user_org_team_permission\" (\"id\" SERIAL NOT NULL, \"createdAt\" TIMESTAMP NOT NULL DEFAULT now(), \"updatedAt\" TIMESTAMP NOT NULL DEFAULT now(), \"userId\" integer NOT NULL, \"orgTeam\" smallint NOT NULL, CONSTRAINT \"PK_user_org_team_permission\" PRIMARY KEY (\"id\"))", + ); + await queryRunner.query( + "CREATE UNIQUE INDEX \"UQ_user_org_team_user_org_team\" ON \"sprocket\".\"user_org_team_permission\" (\"userId\", \"orgTeam\")", + ); + await queryRunner.query( + "CREATE INDEX \"user_org_team_permission_user_id_idx\" ON \"sprocket\".\"user_org_team_permission\" (\"userId\")", + ); + await queryRunner.query( + "ALTER TABLE \"sprocket\".\"user_org_team_permission\" ADD CONSTRAINT \"FK_user_org_team_permission_user\" FOREIGN KEY (\"userId\") REFERENCES \"sprocket\".\"user\"(\"id\") ON DELETE CASCADE ON UPDATE NO ACTION", + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + "ALTER TABLE \"sprocket\".\"user_org_team_permission\" DROP CONSTRAINT \"FK_user_org_team_permission_user\"", + ); + await queryRunner.query( + "DROP INDEX \"sprocket\".\"user_org_team_permission_user_id_idx\"", + ); + await queryRunner.query( + "DROP INDEX \"sprocket\".\"UQ_user_org_team_user_org_team\"", + ); + await queryRunner.query("DROP TABLE \"sprocket\".\"user_org_team_permission\""); + } +} diff --git a/core/src/database/identity/identity.module.ts b/core/src/database/identity/identity.module.ts index b1b00398e..71737b0b7 100644 --- a/core/src/database/identity/identity.module.ts +++ b/core/src/database/identity/identity.module.ts @@ -3,9 +3,10 @@ import {TypeOrmModule} from "@nestjs/typeorm"; import {User} from "./user"; import {UserAuthenticationAccount} from "./user_authentication_account"; +import {UserOrgTeamPermission} from "./user_org_team_permission/user_org_team_permission.model"; import {UserProfile} from "./user_profile"; -export const identityEntities = [User, UserProfile, UserAuthenticationAccount]; +export const identityEntities = [User, UserProfile, UserAuthenticationAccount, UserOrgTeamPermission]; const ormModule = TypeOrmModule.forFeature(identityEntities); diff --git a/core/src/database/identity/user_org_team_permission/user_org_team_permission.model.ts b/core/src/database/identity/user_org_team_permission/user_org_team_permission.model.ts new file mode 100644 index 000000000..08cda766e --- /dev/null +++ b/core/src/database/identity/user_org_team_permission/user_org_team_permission.model.ts @@ -0,0 +1,30 @@ +import { + Column, CreateDateColumn, Entity, Index, JoinColumn, ManyToOne, PrimaryGeneratedColumn, Unique, UpdateDateColumn, +} from "typeorm"; + +import {MLE_OrganizationTeam} from "../../mledb/enums/OrganizationTeam.enum"; +import {User} from "../user/user.model"; + +@Entity({schema: "sprocket"}) +@Unique(["userId", "orgTeam"]) +@Index("user_org_team_permission_user_id_idx", ["userId"]) +export class UserOrgTeamPermission { + @PrimaryGeneratedColumn() + id: number; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; + + @Column({name: "userId", type: "integer"}) + userId: number; + + @ManyToOne(() => User, {onDelete: "CASCADE"}) + @JoinColumn({name: "userId"}) + user: User; + + @Column({name: "orgTeam", type: "smallint"}) + orgTeam: MLE_OrganizationTeam; +} diff --git a/core/src/identity/auth/oauth/oauth.controller.ts b/core/src/identity/auth/oauth/oauth.controller.ts index 930982021..470bbdf88 100644 --- a/core/src/identity/auth/oauth/oauth.controller.ts +++ b/core/src/identity/auth/oauth/oauth.controller.ts @@ -1,9 +1,7 @@ import { Controller, ForbiddenException, - forwardRef, Get, - Inject, Logger, Request, Response, @@ -16,7 +14,7 @@ import type {User} from "$db/identity/user/user.model"; import type {UserAuthenticationAccount} from "$db/identity/user_authentication_account/user_authentication_account.model"; import {UserAuthenticationAccountType} from "$db/identity/user_authentication_account/user_authentication_account_type.enum"; -import {MledbPlayerService} from "../../../mledb"; +import {OrgTeamPermissionResolutionService} from "../../user-org-team-permission/org-team-permission-resolution.service"; import {UserService} from "../../user"; import {DiscordAuthGuard} from "./guards"; import {JwtRefreshGuard} from "./guards/jwt-refresh.guard"; @@ -32,8 +30,7 @@ export class OauthController { constructor( private authService: OauthService, private userService: UserService, - @Inject(forwardRef(() => MledbPlayerService)) - private mledbUserService: MledbPlayerService, + private orgTeamPermissionResolution: OrgTeamPermissionResolutionService, ) {} @Get("login") @@ -46,9 +43,7 @@ export class OauthController { = await this.userService.getUserAuthenticationAccountsForUser(ourUser.id); const discordAccount = authAccounts.find(obj => obj.accountType === UserAuthenticationAccountType.DISCORD); if (discordAccount) { - const player = await this.mledbUserService.getPlayerByDiscordId(discordAccount.accountId); - const player_to_orgs = await this.mledbUserService.getPlayerOrgs(player); - const orgs = player_to_orgs.map(pto => pto.orgTeam); + const orgs = await this.orgTeamPermissionResolution.resolveOrgTeamsForUser(ourUser.id); const payload: AuthPayload = { sub: discordAccount.accountId, username: userProfile.displayName, @@ -73,9 +68,7 @@ export class OauthController { = await this.userService.getUserAuthenticationAccountsForUser(ourUser.userId); const discordAccount = authAccounts.find(obj => obj.accountType === UserAuthenticationAccountType.DISCORD); if (discordAccount) { - const player = await this.mledbUserService.getPlayerByDiscordId(discordAccount.accountId); - const player_to_orgs = await this.mledbUserService.getPlayerOrgs(player); - const orgs = player_to_orgs.map(pto => pto.orgTeam); + const orgs = await this.orgTeamPermissionResolution.resolveOrgTeamsForUser(ourUser.userId); const payload: AuthPayload = { sub: discordAccount.accountId, username: userProfile.displayName, diff --git a/core/src/identity/identity.module.ts b/core/src/identity/identity.module.ts index 69f85553c..9899585e5 100644 --- a/core/src/identity/identity.module.ts +++ b/core/src/identity/identity.module.ts @@ -1,11 +1,18 @@ -import {Module} from "@nestjs/common"; +import {forwardRef, Module} from "@nestjs/common"; import {JwtModule} from "@nestjs/jwt"; import {config} from "@sprocketbot/common"; +import {TypeOrmModule} from "@nestjs/typeorm"; + +import {UserOrgTeamPermission} from "$db/identity/user_org_team_permission/user_org_team_permission.model"; import {DatabaseModule} from "../database"; +import {MledbInterfaceModule} from "../mledb"; import {UtilModule} from "../util/util.module"; import {IdentityController} from "./identity.controller"; import {IdentityService} from "./identity.service"; +import {OrgTeamPermissionResolutionService} from "./user-org-team-permission/org-team-permission-resolution.service"; +import {UserOrgTeamPermissionService} from "./user-org-team-permission/user-org-team-permission.service"; +import {UserOrgTeamPermissionResolver} from "./user-org-team-permission/user-org-team-permission.resolver"; import { UserController, UserResolver, UserService, } from "./user"; @@ -14,14 +21,24 @@ import {UserAuthenticationAccountResolver} from "./user-authentication-account"; @Module({ imports: [ DatabaseModule, + TypeOrmModule.forFeature([UserOrgTeamPermission]), + forwardRef(() => MledbInterfaceModule), UtilModule, JwtModule.register({ secret: config.auth.jwt_secret, signOptions: {expiresIn: config.auth.jwt_expiry}, }), ], - providers: [IdentityService, UserResolver, UserAuthenticationAccountResolver, UserService], - exports: [IdentityService, UserService], + providers: [ + IdentityService, + UserResolver, + UserAuthenticationAccountResolver, + UserService, + UserOrgTeamPermissionService, + UserOrgTeamPermissionResolver, + OrgTeamPermissionResolutionService, + ], + exports: [IdentityService, UserService, UserOrgTeamPermissionService, OrgTeamPermissionResolutionService], controllers: [IdentityController, UserController], }) export class IdentityModule {} diff --git a/core/src/identity/user-org-team-permission/org-team-permission-resolution.service.ts b/core/src/identity/user-org-team-permission/org-team-permission-resolution.service.ts new file mode 100644 index 000000000..5a8baa60f --- /dev/null +++ b/core/src/identity/user-org-team-permission/org-team-permission-resolution.service.ts @@ -0,0 +1,78 @@ +import {forwardRef, Inject, Injectable, Logger} from "@nestjs/common"; + +import type {MLE_OrganizationTeam} from "../../database/mledb"; +import {MledbPlayerService} from "../../mledb/mledb-player/mledb-player.service"; +import {UserOrgTeamPermissionService} from "./user-org-team-permission.service"; + +/** + * Runtime org-team permissions for JWT and guards. + * + * **Source of truth:** `sprocket.user_org_team_permission` (see {@link UserOrgTeamPermissionService}). + * + * **Temporary dual-read:** When `ORG_TEAM_PERMISSION_DUAL_READ=true`, always loads legacy + * `mledb.player_to_org` as well, compares the two sets, and logs on mismatch. Effective permissions + * still prefer Sprocket when it has rows; otherwise MLEDB is used only under dual-read (unbackfilled + * users). Remove the env var and this branch once migration is validated. + */ +@Injectable() +export class OrgTeamPermissionResolutionService { + private readonly logger = new Logger(OrgTeamPermissionResolutionService.name); + + constructor( + private readonly userOrgTeamPermissionService: UserOrgTeamPermissionService, + @Inject(forwardRef(() => MledbPlayerService)) + private readonly mledbPlayerService: MledbPlayerService, + ) {} + + private orgTeamSetsEqual(a: MLE_OrganizationTeam[], b: MLE_OrganizationTeam[]): boolean { + if (a.length !== b.length) return false; + const sb = new Set(b); + return a.every(x => sb.has(x)); + } + + private formatOrgTeamSet(teams: MLE_OrganizationTeam[]): string { + return [...new Set(teams)].sort((x, y) => x - y).join(","); + } + + async resolveOrgTeamsForUser(userId: number): Promise { + const fromSprocket = await this.userOrgTeamPermissionService.listOrgTeamsForUser(userId); + const dualRead = process.env.ORG_TEAM_PERMISSION_DUAL_READ === "true"; + + let fromMledb: MLE_OrganizationTeam[] = []; + if (dualRead) { + try { + const player = await this.mledbPlayerService.getMlePlayerBySprocketUser(userId); + const legacy = await this.mledbPlayerService.getPlayerOrgs(player); + fromMledb = [...new Set(legacy.map(row => row.orgTeam))]; + } catch (err) { + this.logger.verbose( + `Dual-read MLEDB load failed for userId=${userId}: ${(err as Error).message}`, + ); + } + } + + if (dualRead && (fromSprocket.length > 0 || fromMledb.length > 0)) { + if (!this.orgTeamSetsEqual(fromSprocket, fromMledb)) { + const detail + = `userId=${userId} sprocket=[${this.formatOrgTeamSet(fromSprocket)}] mledb=[${this.formatOrgTeamSet(fromMledb)}]`; + if (fromSprocket.length > 0 && fromMledb.length > 0) { + this.logger.warn(`Org-team dual-read mismatch (both non-empty): ${detail}`); + } else if (fromSprocket.length === 0 && fromMledb.length > 0) { + this.logger.verbose( + `Org-team dual-read: no Sprocket rows, MLEDB has org teams (expected until backfill): ${detail}`, + ); + } else { + this.logger.warn(`Org-team dual-read mismatch (Sprocket non-empty, MLEDB empty): ${detail}`); + } + } + } + + if (fromSprocket.length > 0) { + return fromSprocket; + } + if (dualRead && fromMledb.length > 0) { + return fromMledb; + } + return []; + } +} diff --git a/core/src/identity/user-org-team-permission/user-org-team-permission.resolver.ts b/core/src/identity/user-org-team-permission/user-org-team-permission.resolver.ts new file mode 100644 index 000000000..90cdbde18 --- /dev/null +++ b/core/src/identity/user-org-team-permission/user-org-team-permission.resolver.ts @@ -0,0 +1,54 @@ +import {UseGuards} from "@nestjs/common"; +import { + Args, Int, Mutation, Query, registerEnumType, Resolver, +} from "@nestjs/graphql"; + +import {MLE_OrganizationTeam} from "../../database/mledb"; +import {MLEOrganizationTeamGuard} from "../../mledb/mledb-player/mle-organization-team.guard"; +import {GqlJwtGuard} from "../auth/gql-auth-guard"; +import {UserOrgTeamPermissionService} from "./user-org-team-permission.service"; + +registerEnumType(MLE_OrganizationTeam, {name: "MLE_OrganizationTeam"}); + +@Resolver() +export class UserOrgTeamPermissionResolver { + constructor(private readonly permissionService: UserOrgTeamPermissionService) {} + + @Query(() => [MLE_OrganizationTeam]) + @UseGuards(GqlJwtGuard, MLEOrganizationTeamGuard(MLE_OrganizationTeam.MLEDB_ADMIN)) + async userOrgTeamPermissions( + @Args("userId", {type: () => Int}) userId: number, + ): Promise { + return this.permissionService.listOrgTeamsForUser(userId); + } + + @Mutation(() => [MLE_OrganizationTeam]) + @UseGuards(GqlJwtGuard, MLEOrganizationTeamGuard(MLE_OrganizationTeam.MLEDB_ADMIN)) + async setUserOrgTeamPermissions( + @Args("userId", {type: () => Int}) userId: number, + @Args("orgTeams", {type: () => [MLE_OrganizationTeam]}) orgTeams: MLE_OrganizationTeam[], + ): Promise { + await this.permissionService.replaceAllForUser(userId, orgTeams); + return this.permissionService.listOrgTeamsForUser(userId); + } + + @Mutation(() => Boolean) + @UseGuards(GqlJwtGuard, MLEOrganizationTeamGuard(MLE_OrganizationTeam.MLEDB_ADMIN)) + async addUserOrgTeamPermission( + @Args("userId", {type: () => Int}) userId: number, + @Args("orgTeam", {type: () => MLE_OrganizationTeam}) orgTeam: MLE_OrganizationTeam, + ): Promise { + await this.permissionService.addForUser(userId, orgTeam); + return true; + } + + @Mutation(() => Boolean) + @UseGuards(GqlJwtGuard, MLEOrganizationTeamGuard(MLE_OrganizationTeam.MLEDB_ADMIN)) + async removeUserOrgTeamPermission( + @Args("userId", {type: () => Int}) userId: number, + @Args("orgTeam", {type: () => MLE_OrganizationTeam}) orgTeam: MLE_OrganizationTeam, + ): Promise { + await this.permissionService.removeForUser(userId, orgTeam); + return true; + } +} diff --git a/core/src/identity/user-org-team-permission/user-org-team-permission.service.ts b/core/src/identity/user-org-team-permission/user-org-team-permission.service.ts new file mode 100644 index 000000000..5127259bb --- /dev/null +++ b/core/src/identity/user-org-team-permission/user-org-team-permission.service.ts @@ -0,0 +1,44 @@ +import {Injectable} from "@nestjs/common"; +import {InjectRepository} from "@nestjs/typeorm"; +import {In, Repository} from "typeorm"; + +import {UserOrgTeamPermission} from "$db/identity/user_org_team_permission/user_org_team_permission.model"; +import {MLE_OrganizationTeam} from "../../database/mledb"; + +@Injectable() +export class UserOrgTeamPermissionService { + constructor( + @InjectRepository(UserOrgTeamPermission) + private readonly repo: Repository, + ) {} + + async listOrgTeamsForUser(userId: number): Promise { + const rows = await this.repo.find({where: {userId} }); + return [...new Set(rows.map(r => r.orgTeam))]; + } + + async replaceAllForUser(userId: number, orgTeams: MLE_OrganizationTeam[]): Promise { + const unique = [...new Set(orgTeams)]; + await this.repo.manager.transaction(async em => { + await em.delete(UserOrgTeamPermission, {userId}); + if (unique.length === 0) return; + await em.insert( + UserOrgTeamPermission, + unique.map(orgTeam => ({userId, orgTeam})), + ); + }); + } + + async addForUser(userId: number, orgTeam: MLE_OrganizationTeam): Promise { + await this.repo.upsert({userId, orgTeam}, {conflictPaths: ["userId", "orgTeam"]}); + } + + async removeForUser(userId: number, orgTeam: MLE_OrganizationTeam): Promise { + await this.repo.delete({userId, orgTeam}); + } + + async removeAllForUsers(userIds: number[]): Promise { + if (userIds.length === 0) return; + await this.repo.delete({userId: In(userIds)}); + } +} diff --git a/core/src/identity/user/user.resolver.ts b/core/src/identity/user/user.resolver.ts index dd57dbdd5..8c8a90bc0 100644 --- a/core/src/identity/user/user.resolver.ts +++ b/core/src/identity/user/user.resolver.ts @@ -19,6 +19,7 @@ import {UserPayload} from "../auth"; import {CurrentUser} from "../auth/current-user.decorator"; import {GqlJwtGuard} from "../auth/gql-auth-guard"; import {IdentityService} from "../identity.service"; +import {OrgTeamPermissionResolutionService} from "../user-org-team-permission/org-team-permission-resolution.service"; import {UserService} from "./user.service"; @Resolver(() => User) @@ -30,6 +31,7 @@ export class UserResolver { private readonly userService: UserService, private readonly popService: PopulateService, private readonly jwtService: JwtService, + private readonly orgTeamPermissionResolution: OrgTeamPermissionResolutionService, ) {} @Query(() => User) @@ -98,12 +100,13 @@ export class UserResolver { @Args("organizationId", {type: () => Int, nullable: true}) organizationId?: number, ): Promise { const user = await this.userService.getUserById(userId, {relations: {profile: true} }); + const orgTeams = await this.orgTeamPermissionResolution.resolveOrgTeamsForUser(user.id); const payload: AuthPayload = { sub: `${user.id}`, username: user.profile.displayName, userId: user.id, currentOrganizationId: organizationId ?? config.defaultOrganizationId, - orgTeams: [], + orgTeams, }; this.logger.log(`${authedUser.username} (${authedUser.userId}) generated an authentication token for ${user.profile.displayName} (${user.id})`); diff --git a/core/src/mledb/mledb-interface.module.ts b/core/src/mledb/mledb-interface.module.ts index 8d7300504..ab1ffa712 100644 --- a/core/src/mledb/mledb-interface.module.ts +++ b/core/src/mledb/mledb-interface.module.ts @@ -21,6 +21,7 @@ import {SprocketRatingModule} from "../sprocket-rating"; import {UtilModule} from "../util/util.module"; import {MledbMatchController} from "./mledb-match/mledb-match.controller"; import {MledbMatchService} from "./mledb-match/mledb-match.service"; +import {FormerPlayerScrimGuard} from "./mledb-player/mledb-player.guard"; import {MledbPlayerService} from "./mledb-player"; import {MledbPlayerController} from "./mledb-player/mledb-player.controller"; import {MledbPlayerAccountService} from "./mledb-player-account"; @@ -57,12 +58,14 @@ import {MledbNcpTeamRoleUsageResolver, MledbNcpTeamRoleUsageService} from "./mle MledbMatchService, MledbNcpTeamRoleUsageService, MledbNcpTeamRoleUsageResolver, + FormerPlayerScrimGuard, ], exports: [ MledbMatchService, MledbPlayerService, MledbPlayerAccountService, MledbFinalizationService, + FormerPlayerScrimGuard, ], controllers: [MledbMatchController, MledbPlayerController], }) diff --git a/core/src/mledb/mledb-player/mledb-player.guard.ts b/core/src/mledb/mledb-player/mledb-player.guard.ts index a42503a85..50249f62c 100644 --- a/core/src/mledb/mledb-player/mledb-player.guard.ts +++ b/core/src/mledb/mledb-player/mledb-player.guard.ts @@ -5,25 +5,29 @@ import {GraphQLError} from "graphql"; import {MLE_OrganizationTeam} from "../../database/mledb"; import type {UserPayload} from "../../identity"; +import {OrgTeamPermissionResolutionService} from "../../identity/user-org-team-permission/org-team-permission-resolution.service"; import {MledbPlayerService} from "./mledb-player.service"; @Injectable() export class FormerPlayerScrimGuard implements CanActivate { - constructor(private readonly mledbPlayerService: MledbPlayerService) {} + constructor( + private readonly mledbPlayerService: MledbPlayerService, + private readonly orgTeamPermissionResolution: OrgTeamPermissionResolutionService, + ) {} async canActivate(context: ExecutionContext): Promise { const ctx = GqlExecutionContext.create(context); const payload = ctx.getContext().req.user as UserPayload; - const mlePlayer = await this.mledbPlayerService.getMlePlayerBySprocketUser(payload.userId); - - const orgs = await this.mledbPlayerService.getPlayerOrgs(mlePlayer); + const orgTeams = await this.orgTeamPermissionResolution.resolveOrgTeamsForUser(payload.userId); if ( - orgs.some(o => o.orgTeam === MLE_OrganizationTeam.MLEDB_ADMIN - || o.orgTeam === MLE_OrganizationTeam.LEAGUE_OPERATIONS) + orgTeams.some(t => t === MLE_OrganizationTeam.MLEDB_ADMIN + || t === MLE_OrganizationTeam.LEAGUE_OPERATIONS) ) { return true; } + const mlePlayer = await this.mledbPlayerService.getMlePlayerBySprocketUser(payload.userId); + if (mlePlayer.teamName === "FP") throw new GraphQLError("User is a former player in MLE"); return true; diff --git a/core/src/replay-parse/replay-parse.service.ts b/core/src/replay-parse/replay-parse.service.ts index 896e9ca1b..be36c7a94 100644 --- a/core/src/replay-parse/replay-parse.service.ts +++ b/core/src/replay-parse/replay-parse.service.ts @@ -19,7 +19,7 @@ import {GraphQLError} from "graphql"; import type {Readable} from "stream"; import {MLE_OrganizationTeam} from "../database/mledb"; -import {MledbPlayerService} from "../mledb"; +import {OrgTeamPermissionResolutionService} from "../identity/user-org-team-permission/org-team-permission-resolution.service"; import {MemberService} from "../organization"; import {REPLAY_EXT, ReplayParsePubSub} from "./replay-parse.constants"; import type {ReplaySubmission} from "./types"; @@ -36,7 +36,7 @@ export class ReplayParseService { private readonly redisService: RedisService, private readonly eventsService: EventsService, private readonly memberService: MemberService, - private readonly mledbPlayerService: MledbPlayerService, + private readonly orgTeamPermissionResolution: OrgTeamPermissionResolutionService, @Inject(ReplayParsePubSub) private readonly pubsub: PubSub, ) {} @@ -79,19 +79,10 @@ export class ReplayParseService { organizationId, ); - const mlePlayer = await this.mledbPlayerService - .getMlePlayerBySprocketUser(userId) - .catch(() => null); - let override = false; - if (mlePlayer) { - const orgs = await this.mledbPlayerService.getPlayerOrgs(mlePlayer); - if ( - orgs.some(o => o.orgTeam === MLE_OrganizationTeam.MLEDB_ADMIN - || o.orgTeam === MLE_OrganizationTeam.LEAGUE_OPERATIONS) - ) { - override = true; - } - } + const orgTeams = await this.orgTeamPermissionResolution.resolveOrgTeamsForUser(userId); + const override = orgTeams.some( + t => t === MLE_OrganizationTeam.MLEDB_ADMIN || t === MLE_OrganizationTeam.LEAGUE_OPERATIONS, + ); const canSubmitReponse = await this.submissionService.send( SubmissionEndpoint.CanSubmitReplays, diff --git a/infra/platform/Pulumi.prod.yaml b/infra/platform/Pulumi.prod.yaml index 957091f1e..74f86d1c5 100644 --- a/infra/platform/Pulumi.prod.yaml +++ b/infra/platform/Pulumi.prod.yaml @@ -44,3 +44,5 @@ config: secure: AAABAJ4n6UM4nXqBZf0qVCT5dW8qHGcrkRdKKD50qD4OZly3KNQMll9C/N9p+eOfQJGhZMLQ2YY= platform:ballchasing-token: secure: AAABACy991gu0bWFl5MNxrbfIqG7wJbnetYPCBFZR4ciVpVmytv5rZELSCz3VEtOiBrUrjLvhOjRIpNY5x64n0yw75BrUIeE + # Issue #726: allow legacy mledb.player_to_org fallback until Sprocket rows are backfilled; set false after rollout. + platform:org-team-permission-dual-read: "true" diff --git a/infra/platform/src/Platform.ts b/infra/platform/src/Platform.ts index 5c735c7c1..9a845e073 100644 --- a/infra/platform/src/Platform.ts +++ b/infra/platform/src/Platform.ts @@ -18,6 +18,10 @@ import { LegacyPlatform } from './legacy/LegacyPlatform'; const config = new pulumi.Config() const imageNamespace = config.require("image-namespace") +/** When true, core reads legacy mledb.player_to_org only if the user has no Sprocket org-team rows (see issue #726). */ +const orgTeamPermissionDualRead = config.getBoolean("org-team-permission-dual-read") ?? false +const orgTeamPermissionDualReadEnv = orgTeamPermissionDualRead ? "true" : "false" + export interface PlatformArgs { postgresHostname: string | pulumi.Output postgresPort: number | pulumi.Output @@ -170,6 +174,7 @@ export class Platform extends pulumi.ComponentResource { env: { NODE_ENV: "production", ENV: "production", + ORG_TEAM_PERMISSION_DUAL_READ: orgTeamPermissionDualReadEnv, }, secrets: [{ secretId: this.secrets.jwtSecret.id, @@ -241,6 +246,7 @@ export class Platform extends pulumi.ComponentResource { CACHE_HOST: this.datastore.redis.hostname, CACHE_PORT: "6379", LOGGER_LEVELS: '["error","warn","log","debug"]', + ORG_TEAM_PERMISSION_DUAL_READ: orgTeamPermissionDualReadEnv, }, secrets: [{ secretId: this.secrets.jwtSecret.id, diff --git a/reports/issue-726-org-team-permissions.md b/reports/issue-726-org-team-permissions.md new file mode 100644 index 000000000..63ef34a27 --- /dev/null +++ b/reports/issue-726-org-team-permissions.md @@ -0,0 +1,28 @@ +# Issue 726: Org-team permissions (Sprocket source of truth) + +## Runtime source of truth + +League org-team / LO-style permission bits used in JWTs and GraphQL guards are stored in **`sprocket.user_org_team_permission`**, one row per `(userId, orgTeam)` where `orgTeam` is the numeric `MLE_OrganizationTeam` enum. + +Core APIs: + +- `UserOrgTeamPermissionService` — read/write against that table. +- `OrgTeamPermissionResolutionService` — resolves the org-team list for a Sprocket user id (used at login, refresh, and a few services/guards). + +Admin GraphQL (MLEDB admin guard): `userOrgTeamPermissions`, `setUserOrgTeamPermissions`, `addUserOrgTeamPermission`, `removeUserOrgTeamPermission`. + +## Temporary dual-read from MLEDB + +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). + +**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. + +**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. + +**Backfill:** run `scripts/sql/backfill-user-org-team-permission-from-mledb.sql` against prod Postgres (same DB as `mledb` + `sprocket`). + +**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. + +## Migration + +Apply TypeORM migration `1770500000000-UserOrgTeamPermission` (creates `sprocket.user_org_team_permission`). diff --git a/scripts/sql/backfill-user-org-team-permission-from-mledb.sql b/scripts/sql/backfill-user-org-team-permission-from-mledb.sql new file mode 100644 index 000000000..0c7c5724a --- /dev/null +++ b/scripts/sql/backfill-user-org-team-permission-from-mledb.sql @@ -0,0 +1,34 @@ +-- Backfill Sprocket org-team permissions from legacy MLEDB (issue #726). +-- +-- Prerequisites: +-- 1. Migration that creates sprocket.user_org_team_permission has been applied. +-- 2. Run against the same database that holds both `mledb` and `sprocket` schemas. +-- +-- This maps mledb.player -> discord_id -> sprocket.user_authentication_account (DISCORD) +-- and copies distinct (userId, org_team) pairs. Idempotent: safe to re-run. +-- +-- After verifying counts and a spot-check of JWTs, set ORG_TEAM_PERMISSION_DUAL_READ=false +-- in prod Pulumi and redeploy core. + +BEGIN; + +INSERT INTO sprocket.user_org_team_permission ("userId", "orgTeam", "createdAt", "updatedAt") +SELECT DISTINCT + uaa."userId" AS "userId", + pto.org_team::smallint AS "orgTeam", + now() AS "createdAt", + now() AS "updatedAt" +FROM mledb.player_to_org pto +INNER JOIN mledb.player p ON p.id = pto.player_id +INNER JOIN sprocket.user_authentication_account uaa + ON uaa."accountId" = p.discord_id + AND uaa."accountType" = 'DISCORD'::sprocket.user_authentication_account_accounttype_enum +WHERE p.discord_id IS NOT NULL + AND btrim(p.discord_id) <> '' +ON CONFLICT ("userId", "orgTeam") DO NOTHING; + +COMMIT; + +-- Optional diagnostics (run separately if you want a summary before COMMIT): +-- SELECT count(*) FROM sprocket.user_org_team_permission; +-- SELECT pto.org_team, count(*) FROM mledb.player_to_org pto GROUP BY 1 ORDER BY 1;