-
Notifications
You must be signed in to change notification settings - Fork 107
feat: Org/Team permission model with private package support #1018
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
3a5a244
0d7e800
bc09873
25e6548
03fffec
17eeebc
b4e2a0f
b244075
e1d30f0
57a7d78
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,29 @@ | ||
| import { EasyData, EntityUtil } from '../util/EntityUtil.ts'; | ||
| import { Entity, EntityData } from './Entity.ts'; | ||
|
|
||
| interface OrgData extends EntityData { | ||
| orgId: string; | ||
| name: string; | ||
| description: string; | ||
| } | ||
|
|
||
| export type CreateOrgData = Omit<EasyData<OrgData, 'orgId'>, 'id' | 'description'> & { description?: string }; | ||
|
|
||
| export class Org extends Entity { | ||
| orgId: string; | ||
| name: string; | ||
| description: string; | ||
|
|
||
| constructor(data: OrgData) { | ||
| super(data); | ||
| this.orgId = data.orgId; | ||
| this.name = data.name; | ||
| this.description = data.description ?? ''; | ||
| } | ||
|
|
||
| static create(data: CreateOrgData): Org { | ||
| const fullData = { ...data, description: data.description ?? '' }; | ||
| const newData = EntityUtil.defaultData(fullData, 'orgId'); | ||
| return new Org(newData); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,31 @@ | ||
| import { EasyData, EntityUtil } from '../util/EntityUtil.ts'; | ||
| import { Entity, EntityData } from './Entity.ts'; | ||
|
|
||
| interface OrgMemberData extends EntityData { | ||
| orgMemberId: string; | ||
| orgId: string; | ||
| userId: string; | ||
| role: 'owner' | 'member'; | ||
| } | ||
|
|
||
| export type CreateOrgMemberData = Omit<EasyData<OrgMemberData, 'orgMemberId'>, 'id'>; | ||
|
|
||
| export class OrgMember extends Entity { | ||
| orgMemberId: string; | ||
| orgId: string; | ||
| userId: string; | ||
| role: 'owner' | 'member'; | ||
|
|
||
| constructor(data: OrgMemberData) { | ||
| super(data); | ||
| this.orgMemberId = data.orgMemberId; | ||
| this.orgId = data.orgId; | ||
| this.userId = data.userId; | ||
| this.role = data.role; | ||
| } | ||
|
|
||
| static create(data: CreateOrgMemberData): OrgMember { | ||
| const newData = EntityUtil.defaultData(data, 'orgMemberId'); | ||
| return new OrgMember(newData); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,32 @@ | ||
| import { EasyData, EntityUtil } from '../util/EntityUtil.ts'; | ||
| import { Entity, EntityData } from './Entity.ts'; | ||
|
|
||
| interface TeamData extends EntityData { | ||
| teamId: string; | ||
| orgId: string; | ||
| name: string; | ||
| description: string; | ||
| } | ||
|
|
||
| export type CreateTeamData = Omit<EasyData<TeamData, 'teamId'>, 'id' | 'description'> & { description?: string }; | ||
|
|
||
| export class Team extends Entity { | ||
| teamId: string; | ||
| orgId: string; | ||
| name: string; | ||
| description: string; | ||
|
|
||
| constructor(data: TeamData) { | ||
| super(data); | ||
| this.teamId = data.teamId; | ||
| this.orgId = data.orgId; | ||
| this.name = data.name; | ||
| this.description = data.description ?? ''; | ||
| } | ||
|
|
||
| static create(data: CreateTeamData): Team { | ||
| const fullData = { ...data, description: data.description ?? '' }; | ||
| const newData = EntityUtil.defaultData(fullData, 'teamId'); | ||
| return new Team(newData); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,28 @@ | ||
| import { EasyData, EntityUtil } from '../util/EntityUtil.ts'; | ||
| import { Entity, EntityData } from './Entity.ts'; | ||
|
|
||
| interface TeamMemberData extends EntityData { | ||
| teamMemberId: string; | ||
| teamId: string; | ||
| userId: string; | ||
| } | ||
|
|
||
| export type CreateTeamMemberData = Omit<EasyData<TeamMemberData, 'teamMemberId'>, 'id'>; | ||
|
|
||
| export class TeamMember extends Entity { | ||
| teamMemberId: string; | ||
| teamId: string; | ||
| userId: string; | ||
|
|
||
| constructor(data: TeamMemberData) { | ||
| super(data); | ||
| this.teamMemberId = data.teamMemberId; | ||
| this.teamId = data.teamId; | ||
| this.userId = data.userId; | ||
| } | ||
|
|
||
| static create(data: CreateTeamMemberData): TeamMember { | ||
| const newData = EntityUtil.defaultData(data, 'teamMemberId'); | ||
| return new TeamMember(newData); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,28 @@ | ||
| import { EasyData, EntityUtil } from '../util/EntityUtil.ts'; | ||
| import { Entity, EntityData } from './Entity.ts'; | ||
|
|
||
| interface TeamPackageData extends EntityData { | ||
| teamPackageId: string; | ||
| teamId: string; | ||
| packageId: string; | ||
| } | ||
|
|
||
| export type CreateTeamPackageData = Omit<EasyData<TeamPackageData, 'teamPackageId'>, 'id'>; | ||
|
|
||
| export class TeamPackage extends Entity { | ||
| teamPackageId: string; | ||
| teamId: string; | ||
| packageId: string; | ||
|
|
||
| constructor(data: TeamPackageData) { | ||
| super(data); | ||
| this.teamPackageId = data.teamPackageId; | ||
| this.teamId = data.teamId; | ||
| this.packageId = data.packageId; | ||
| } | ||
|
|
||
| static create(data: CreateTeamPackageData): TeamPackage { | ||
| const newData = EntityUtil.defaultData(data, 'teamPackageId'); | ||
| return new TeamPackage(newData); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,141 @@ | ||||||||||||||||||||||
| import { AccessLevel, Inject, SingletonProto } from 'egg'; | ||||||||||||||||||||||
| import { ForbiddenError, NotFoundError } from 'egg/errors'; | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| import { AbstractService } from '../../common/AbstractService.ts'; | ||||||||||||||||||||||
| import { DEVELOPERS_TEAM } from '../../common/constants.ts'; | ||||||||||||||||||||||
| import type { OrgRepository } from '../../repository/OrgRepository.ts'; | ||||||||||||||||||||||
| import type { TeamRepository } from '../../repository/TeamRepository.ts'; | ||||||||||||||||||||||
| import { Org } from '../entity/Org.ts'; | ||||||||||||||||||||||
| import { OrgMember } from '../entity/OrgMember.ts'; | ||||||||||||||||||||||
| import { Team } from '../entity/Team.ts'; | ||||||||||||||||||||||
| import { TeamMember } from '../entity/TeamMember.ts'; | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| export interface CreateOrgCmd { | ||||||||||||||||||||||
| name: string; | ||||||||||||||||||||||
| description?: string; | ||||||||||||||||||||||
| creatorUserId: string; | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| @SingletonProto({ | ||||||||||||||||||||||
| accessLevel: AccessLevel.PUBLIC, | ||||||||||||||||||||||
| }) | ||||||||||||||||||||||
| export class OrgService extends AbstractService { | ||||||||||||||||||||||
| @Inject() | ||||||||||||||||||||||
| private readonly orgRepository: OrgRepository; | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| @Inject() | ||||||||||||||||||||||
| private readonly teamRepository: TeamRepository; | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| async createOrg(cmd: CreateOrgCmd): Promise<Org> { | ||||||||||||||||||||||
| const existing = await this.orgRepository.findOrgByName(cmd.name); | ||||||||||||||||||||||
| if (existing) { | ||||||||||||||||||||||
| throw new ForbiddenError(`Org "${cmd.name}" already exists`); | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| // Create org + developers team + owner + team member in one transaction | ||||||||||||||||||||||
| const org = Org.create({ | ||||||||||||||||||||||
| name: cmd.name, | ||||||||||||||||||||||
| description: cmd.description, | ||||||||||||||||||||||
| }); | ||||||||||||||||||||||
| const developersTeam = Team.create({ | ||||||||||||||||||||||
| orgId: org.orgId, | ||||||||||||||||||||||
| name: DEVELOPERS_TEAM, | ||||||||||||||||||||||
| description: 'default team', | ||||||||||||||||||||||
| }); | ||||||||||||||||||||||
| const ownerMember = OrgMember.create({ | ||||||||||||||||||||||
| orgId: org.orgId, | ||||||||||||||||||||||
| userId: cmd.creatorUserId, | ||||||||||||||||||||||
| role: 'owner', | ||||||||||||||||||||||
| }); | ||||||||||||||||||||||
| const teamMember = TeamMember.create({ | ||||||||||||||||||||||
| teamId: developersTeam.teamId, | ||||||||||||||||||||||
| userId: cmd.creatorUserId, | ||||||||||||||||||||||
| }); | ||||||||||||||||||||||
| await this.orgRepository.createOrgCascade(org, developersTeam, ownerMember, teamMember); | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| this.logger.info( | ||||||||||||||||||||||
| '[OrgService:createOrg] orgId: %s, name: %s, creatorUserId: %s', | ||||||||||||||||||||||
| org.orgId, | ||||||||||||||||||||||
| org.name, | ||||||||||||||||||||||
| cmd.creatorUserId, | ||||||||||||||||||||||
| ); | ||||||||||||||||||||||
| return org; | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| async removeOrg(orgId: string): Promise<void> { | ||||||||||||||||||||||
| await this.orgRepository.removeOrgCascade(orgId); | ||||||||||||||||||||||
| this.logger.info('[OrgService:removeOrg] orgId: %s', orgId); | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| async findOrgByName(name: string): Promise<Org | null> { | ||||||||||||||||||||||
| return await this.orgRepository.findOrgByName(name); | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| // Auto-create org for allowScopes if it doesn't exist | ||||||||||||||||||||||
| async ensureOrgForScope(scope: string): Promise<Org> { | ||||||||||||||||||||||
| const orgName = scope.replace(/^@/, ''); | ||||||||||||||||||||||
| const existing = await this.orgRepository.findOrgByName(orgName); | ||||||||||||||||||||||
| if (existing) return existing; | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| const org = Org.create({ | ||||||||||||||||||||||
| name: orgName, | ||||||||||||||||||||||
| description: `Auto-created org for scope ${scope}`, | ||||||||||||||||||||||
| }); | ||||||||||||||||||||||
| await this.orgRepository.saveOrg(org); | ||||||||||||||||||||||
|
||||||||||||||||||||||
| await this.orgRepository.saveOrg(org); | |
| await this.orgRepository.saveOrg(org); | |
| // Ensure default developers team exists for this org, matching createOrg() behavior | |
| const developersTeam = Team.create({ | |
| orgId: org.orgId, | |
| name: DEVELOPERS_TEAM, | |
| description: 'Default developers team', | |
| }); | |
| await this.teamRepository.saveTeam(developersTeam); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Avoid the check-then-insert race in org creation.
Both
createOrg()andensureOrgForScope()dofindOrgByName()before inserting. Concurrent requests for the same name can pass that existence check together and then collide increateOrgCascade()/saveOrg(), which means either duplicate org rows or a raw DB uniqueness error escaping to the caller. Please make org creation idempotent at the repository/DB boundary and re-read on conflict.Also applies to: 75-85
🤖 Prompt for AI Agents