Skip to content

Commit 8d92ae4

Browse files
committed
refactor(service): users/auth: refactor some app level logic to internal/domain services for reuse
1 parent 57f8bb2 commit 8d92ae4

File tree

7 files changed

+141
-55
lines changed

7 files changed

+141
-55
lines changed

service/src/ingress/ingress.adapters.db.mongoose.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -157,9 +157,9 @@ export class UserIngressBindingsMongooseRepository implements UserIngressBinding
157157

158158
constructor(readonly model: UserIngressBindingsModel) {}
159159

160-
async readBindingsForUser(userId: UserId): Promise<UserIngressBindings | null> {
160+
async readBindingsForUser(userId: UserId): Promise<UserIngressBindings> {
161161
const doc = await this.model.findById(userId, null, { lean: true })
162-
return doc ? { userId, bindingsByIdp: new Map(Object.entries(doc?.bindings || {})) } : null
162+
return { userId, bindingsByIdpId: new Map(Object.entries(doc?.bindings || {})) }
163163
}
164164

165165
async readAllBindingsForIdp(idpId: IdentityProviderId, paging?: PagingParameters | undefined): Promise<PageOf<UserIngressBindings>> {
@@ -170,7 +170,7 @@ export class UserIngressBindingsMongooseRepository implements UserIngressBinding
170170
const _id = new ObjectId(userId)
171171
const bindingsUpdate = { $set: { [`bindings.${binding.idpId}`]: binding } }
172172
const doc = await this.model.findOneAndUpdate({ _id }, bindingsUpdate, { upsert: true, new: true })
173-
return { userId, bindingsByIdp: new Map(Object.entries(doc.bindings)) }
173+
return { userId, bindingsByIdpId: new Map(Object.entries(doc.bindings)) }
174174
}
175175

176176
async deleteBinding(userId: UserId, idpId: IdentityProviderId): Promise<UserIngressBinding | null> {

service/src/ingress/ingress.app.api.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ export interface AdmitFromIdentityProviderRequest {
4141
}
4242

4343
export interface AdmitFromIdentityProviderResult {
44-
mageAccount: User
44+
mageAccount: UserExpanded
4545
admissionToken: string
4646
}
4747

service/src/ingress/ingress.app.impl.ts

Lines changed: 15 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,8 @@
11
import { entityNotFound, infrastructureError } from '../app.api/app.api.errors'
22
import { AppResponse } from '../app.api/app.api.global'
3-
import { UserRepository } from '../entities/users/entities.users'
43
import { AdmitFromIdentityProviderOperation, AdmitFromIdentityProviderRequest, authenticationFailedError, EnrollMyselfOperation, EnrollMyselfRequest } from './ingress.app.api'
5-
import { IdentityProviderRepository, IdentityProviderUser, UserIngressBindingsRepository } from './ingress.entities'
6-
import { EnrollNewUser } from './ingress.services.api'
4+
import { IdentityProviderRepository, IdentityProviderUser } from './ingress.entities'
5+
import { AdmissionDeniedReason, AdmitUserFromIdentityProviderAccount, EnrollNewUser } from './ingress.services.api'
76
import { LocalIdpCreateAccountOperation } from './local-idp.app.api'
87
import { JWTService, TokenAssertion } from './verification'
98

@@ -32,55 +31,38 @@ export function CreateEnrollMyselfOperation(createLocalIdpAccount: LocalIdpCreat
3231
}
3332
const enrollmentResult = await enrollNewUser(candidateMageAccount, localIdp)
3433

34+
3535
// TODO: auto-activate account after enrollment policy
3636
throw new Error('unimplemented')
3737
}
3838
}
3939

40-
export function CreateAdmitFromIdentityProviderOperation(idpRepo: IdentityProviderRepository, ingressBindingRepo: UserIngressBindingsRepository, userRepo: UserRepository, enrollNewUser: EnrollNewUser, tokenService: JWTService): AdmitFromIdentityProviderOperation {
40+
export function CreateAdmitFromIdentityProviderOperation(idpRepo: IdentityProviderRepository, admitFromIdpAccount: AdmitUserFromIdentityProviderAccount, tokenService: JWTService): AdmitFromIdentityProviderOperation {
4141
return async function admitFromIdentityProvider(req: AdmitFromIdentityProviderRequest): ReturnType<AdmitFromIdentityProviderOperation> {
4242
const idp = await idpRepo.findIdpByName(req.identityProviderName)
4343
if (!idp) {
4444
return AppResponse.error(entityNotFound(req.identityProviderName, 'IdentityProvider', `identity provider not found: ${req.identityProviderName}`))
4545
}
4646
const idpAccount = req.identityProviderUser
4747
console.info(`admitting user ${idpAccount.username} from identity provider ${idp.name}`)
48-
const mageAccount = await userRepo.findByUsername(idpAccount.username)
49-
.then(existingAccount => {
50-
if (existingAccount) {
51-
return ingressBindingRepo.readBindingsForUser(existingAccount.id).then(ingressBindings => {
52-
return { mageAccount: existingAccount, ingressBindings }
53-
})
48+
const admission = await admitFromIdpAccount(idpAccount, idp)
49+
if (admission.action === 'denied') {
50+
if (admission.mageAccount) {
51+
if (admission.reason === AdmissionDeniedReason.PendingApproval) {
52+
return AppResponse.error(authenticationFailedError(admission.mageAccount.username, idp.name, 'Your account requires approval from a Mage administrator.'))
5453
}
55-
return enrollNewUser(idpAccount, idp)
56-
})
57-
.then(enrolled => {
58-
const { mageAccount, ingressBindings } = enrolled
59-
if (ingressBindings.bindingsByIdp.has(idp.id)) {
60-
return mageAccount
54+
if (admission.reason === AdmissionDeniedReason.Disabled) {
55+
return AppResponse.error(authenticationFailedError(admission.mageAccount.username, idp.name, 'Your account is disabled.'))
6156
}
62-
console.error(`user ${mageAccount.username} has no ingress binding to identity provider ${idp.name}`)
63-
return null
64-
})
65-
.catch(err => {
66-
console.error(`error creating user account ${idpAccount.username} from identity provider ${idp.name}`, err)
67-
return null
68-
})
69-
if (!mageAccount) {
57+
}
7058
return AppResponse.error(authenticationFailedError(idpAccount.username, idp.name))
7159
}
72-
if (!mageAccount.active) {
73-
return AppResponse.error(authenticationFailedError(mageAccount.username, idp.name, 'Your account requires approval from a Mage administrator.'))
74-
}
75-
if (!mageAccount.enabled) {
76-
return AppResponse.error(authenticationFailedError(mageAccount.username, idp.name, 'Your account is disabled.'))
77-
}
7860
try {
79-
const admissionToken = await tokenService.generateToken(mageAccount.id, TokenAssertion.Authenticated, 5 * 60)
80-
return AppResponse.success({ mageAccount, admissionToken })
61+
const admissionToken = await tokenService.generateToken(admission.mageAccount.id, TokenAssertion.Authenticated, 5 * 60)
62+
return AppResponse.success({ mageAccount: admission.mageAccount, admissionToken })
8163
}
8264
catch (err) {
83-
console.error(`error generating admission token while authenticating user ${mageAccount.username}`, err)
65+
console.error(`error generating admission token while authenticating user ${admission.mageAccount.username}`, err)
8466
return AppResponse.error(infrastructureError('An unexpected error occurred while generating an authentication token.'))
8567
}
8668
}

service/src/ingress/ingress.entities.ts

Lines changed: 26 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -105,15 +105,13 @@ export interface UserIngressBinding {
105105
* multiple ingress bindings for different identity providers with different account identifiers.
106106
*/
107107
idpAccountId?: string
108-
/**
109-
* Any attributes the identity provider or protocol needs to persist about the account mapping
110-
*/
111-
idpAccountAttrs?: Record<string, any>
108+
// TODO: unused for now
109+
// idpAccountAttrs?: Record<string, any>
112110
}
113111

114112
export type UserIngressBindings = {
115113
userId: UserId
116-
bindingsByIdp: Map<IdentityProviderId, UserIngressBinding>
114+
bindingsByIdpId: Map<IdentityProviderId, UserIngressBinding>
117115
}
118116

119117
export type IdentityProviderMutableAttrs = Omit<IdentityProvider, 'id' | 'name' | 'protocol'>
@@ -133,7 +131,7 @@ export interface UserIngressBindingsRepository {
133131
/**
134132
* Return null if the user has no persisted bindings entry.
135133
*/
136-
readBindingsForUser(userId: UserId): Promise<UserIngressBindings | null>
134+
readBindingsForUser(userId: UserId): Promise<UserIngressBindings>
137135
readAllBindingsForIdp(idpId: IdentityProviderId, paging?: PagingParameters): Promise<PageOf<UserIngressBindings>>
138136
/**
139137
* Save the given ingress binding to the bindings dictionary for the given user, creating or updating as necessary.
@@ -154,10 +152,29 @@ export interface UserIngressBindingsRepository {
154152
deleteAllBindingsForIdp(idpId: IdentityProviderId): Promise<number>
155153
}
156154

155+
export type AdmissionAction =
156+
| { admitNew: UserIngressBinding, admitExisting: false, deny: false }
157+
| { admitExisting: UserIngressBinding, admitNew: false, deny: false }
158+
| { deny: true, admitNew: false, admitExisting: false }
159+
160+
export function determinUserIngressBindingAdmission(idpAccount: IdentityProviderUser, idp: IdentityProvider, bindings: UserIngressBindings): AdmissionAction {
161+
if (bindings.bindingsByIdpId.size === 0) {
162+
// new user account
163+
const now = new Date(Date.now())
164+
return { admitNew: { created: now, updated: now, idpId: idp.id, idpAccountId: idpAccount.idpAccountId }, admitExisting: false, deny: false }
165+
}
166+
const binding = bindings.bindingsByIdpId.get(idp.id)
167+
if (binding) {
168+
// existing account bound to idp
169+
return { admitExisting: binding, admitNew: false, deny: false }
170+
}
171+
return { deny: true, admitNew: false, admitExisting: false }
172+
}
173+
157174
/**
158-
* Return a new user object from the given identity provider account information suitable to persist as newly enrolled
159-
* user. The enrollment policy for the identity provider determines the `active` flag and assigned role for the new
160-
* user.
175+
* Return a new user object from the given identity provider account information suitable to persist as a newly
176+
* enrolled user. The enrollment policy for the identity provider determines the `active` flag and assigned role for
177+
* the new user.
161178
*/
162179
export function createEnrollmentCandidateUser(idpAccount: IdentityProviderUser, idp: IdentityProvider): Omit<User, 'id'> {
163180
const policy = idp.userEnrollmentPolicy
Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,39 @@
1-
import { User } from '../entities/users/entities.users'
1+
import { UserExpanded } from '../entities/users/entities.users'
22
import { IdentityProvider, IdentityProviderUser, UserIngressBindings } from './ingress.entities'
33

4+
export type AdmissionResult =
5+
| {
6+
/**
7+
* `'admitted'` if the user account is valid for admission and access to Mage, `'denied'` otherwise
8+
*/
9+
action: 'admitted',
10+
/**
11+
* The existing or newly enrolled Mage account
12+
*/
13+
mageAccount: UserExpanded,
14+
/**
15+
* Whether the admission resulted in a new Mage account enrollment
16+
*/
17+
enrolled: boolean,
18+
}
19+
| {
20+
action: 'denied',
21+
reason: AdmissionDeniedReason,
22+
mageAccount: UserExpanded | null,
23+
enrolled: boolean,
24+
}
25+
26+
export enum AdmissionDeniedReason {
27+
PendingApproval = 'PendingApproval',
28+
Disabled = 'Disabled',
29+
NameConflict = 'NameConflict',
30+
InternalError = 'InternalError',
31+
}
32+
33+
export interface AdmitUserFromIdentityProviderAccount {
34+
(idpAccount: IdentityProviderUser, idp: IdentityProvider): Promise<AdmissionResult>
35+
}
36+
437
export interface EnrollNewUser {
5-
(idpAccount: IdentityProviderUser, idp: IdentityProvider): Promise<{ mageAccount: User, ingressBindings: UserIngressBindings }>
38+
(idpAccount: IdentityProviderUser, idp: IdentityProvider): Promise<{ mageAccount: UserExpanded, ingressBindings: UserIngressBindings }>
639
}

service/src/ingress/ingress.services.impl.ts

Lines changed: 60 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
import { MageEventId } from '../entities/events/entities.events'
22
import { Team, TeamId } from '../entities/teams/entities.teams'
3-
import { User, UserId, UserRepository, UserRepositoryError } from '../entities/users/entities.users'
4-
import { createEnrollmentCandidateUser, IdentityProvider, IdentityProviderUser, UserIngressBindingsRepository, UserIngressBindings } from './ingress.entities'
5-
import { EnrollNewUser } from './ingress.services.api'
3+
import { UserExpanded, UserId, UserRepository, UserRepositoryError } from '../entities/users/entities.users'
4+
import { createEnrollmentCandidateUser, IdentityProvider, IdentityProviderUser, UserIngressBindingsRepository, UserIngressBindings, determinUserIngressBindingAdmission } from './ingress.entities'
5+
import { AdmissionDeniedReason, AdmissionResult, AdmitUserFromIdentityProviderAccount, EnrollNewUser } from './ingress.services.api'
66

77
export interface AssignTeamMember {
88
(member: UserId, team: TeamId): Promise<boolean>
@@ -12,20 +12,74 @@ export interface FindEventTeam {
1212
(mageEventId: MageEventId): Promise<Team | null>
1313
}
1414

15-
export function CreateProcessNewUserEnrollmentService(userRepo: UserRepository, ingressBindingRepo: UserIngressBindingsRepository, findEventTeam: FindEventTeam, assignTeamMember: AssignTeamMember): EnrollNewUser {
16-
return async function processNewUserEnrollment(idpAccount: IdentityProviderUser, idp: IdentityProvider): Promise<{ mageAccount: User, ingressBindings: UserIngressBindings }> {
15+
export function CreateUserAdmissionService(userRepo: UserRepository, ingressBindingRepo: UserIngressBindingsRepository, enrollNewUser: EnrollNewUser): AdmitUserFromIdentityProviderAccount {
16+
return async function(idpAccount: IdentityProviderUser, idp: IdentityProvider): Promise<AdmissionResult> {
17+
return userRepo.findByUsername(idpAccount.username)
18+
.then(existingAccount => {
19+
if (existingAccount) {
20+
return ingressBindingRepo.readBindingsForUser(existingAccount.id).then(ingressBindings => {
21+
return { enrolled: false, mageAccount: existingAccount, ingressBindings }
22+
})
23+
}
24+
console.info(`enrolling new user account ${idpAccount.username} from identity provider ${idp.name}`)
25+
return enrollNewUser(idpAccount, idp).then(enrollment => ({ enrolled: true, ...enrollment }))
26+
})
27+
.then<AdmissionResult>(userIngress => {
28+
const { enrolled, mageAccount, ingressBindings } = userIngress
29+
const idpAdmission = determinUserIngressBindingAdmission(idpAccount, idp, ingressBindings)
30+
if (idpAdmission.deny) {
31+
console.error(`user ${mageAccount.username} has no ingress binding to identity provider ${idp.name}`)
32+
return { action: 'denied', reason: AdmissionDeniedReason.NameConflict, enrolled, mageAccount }
33+
}
34+
if (idpAdmission.admitNew) {
35+
return ingressBindingRepo.saveUserIngressBinding(mageAccount.id, idpAdmission.admitNew)
36+
.then<AdmissionResult>(() => ({ action: 'admitted', mageAccount, enrolled }))
37+
.catch(err => {
38+
console.error(`error saving ingress binding for user ${mageAccount.username} to idp ${idp.name}`, err)
39+
return { action: 'denied', reason: AdmissionDeniedReason.InternalError, mageAccount, enrolled }
40+
})
41+
}
42+
return { action: 'admitted', mageAccount, enrolled }
43+
})
44+
.then<AdmissionResult>(userIngress => {
45+
const { action, mageAccount, enrolled } = userIngress
46+
if (!mageAccount) {
47+
return { action: 'denied', reason: AdmissionDeniedReason.InternalError, mageAccount, enrolled }
48+
}
49+
if (action === 'denied') {
50+
return userIngress
51+
}
52+
if (!mageAccount.active) {
53+
return { action: 'denied', reason: AdmissionDeniedReason.PendingApproval, mageAccount, enrolled }
54+
}
55+
if (!mageAccount.enabled) {
56+
return { action: 'denied', reason: AdmissionDeniedReason.Disabled, mageAccount, enrolled }
57+
}
58+
return userIngress
59+
})
60+
.catch<AdmissionResult>(err => {
61+
console.error(`error admitting user account ${idpAccount.username} from identity provider ${idp.name}`, err)
62+
return { action: 'denied', reason: AdmissionDeniedReason.InternalError, enrolled: false, mageAccount: null }
63+
})
64+
}
65+
}
66+
67+
export function CreateNewUserEnrollmentService(userRepo: UserRepository, ingressBindingRepo: UserIngressBindingsRepository, findEventTeam: FindEventTeam, assignTeamMember: AssignTeamMember): EnrollNewUser {
68+
return async function processNewUserEnrollment(idpAccount: IdentityProviderUser, idp: IdentityProvider): Promise<{ mageAccount: UserExpanded, ingressBindings: UserIngressBindings }> {
1769
console.info(`enrolling new user account ${idpAccount.username} from identity provider ${idp.name}`)
1870
const candidate = createEnrollmentCandidateUser(idpAccount, idp)
1971
const mageAccount = await userRepo.create(candidate)
2072
if (mageAccount instanceof UserRepositoryError) {
2173
throw mageAccount
2274
}
75+
const now = new Date()
2376
const ingressBindings = await ingressBindingRepo.saveUserIngressBinding(
2477
mageAccount.id,
2578
{
2679
idpId: idp.id,
2780
idpAccountId: idpAccount.username,
28-
idpAccountAttrs: {},
81+
created: now,
82+
updated: now,
2983
}
3084
)
3185
if (ingressBindings instanceof Error) {

service/src/ingress/local-idp.app.impl.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ export function CreateLocalIdpCreateAccountOperation(repo: LocalIdpRepository):
3636
const createdAccount = await repo.createLocalAccount(candidateAccount)
3737
if (createdAccount instanceof LocalIdpError) {
3838
if (createdAccount instanceof LocalIdpDuplicateUsernameError) {
39-
console.info(`attempted to create local account with duplicate username ${req.username}`)
39+
console.error(`attempted to create local account with duplicate username ${req.username}`, createdAccount)
4040
}
4141
return AppResponse.error(invalidInput(`Failed to create account ${req.username}.`))
4242
}

0 commit comments

Comments
 (0)