Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
12 changes: 7 additions & 5 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

130 changes: 130 additions & 0 deletions packages/user-management/__tests__/permissions.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import { test, describe } from 'node:test'
import assert from 'node:assert/strict'
import { hasApplicationPermission, hasPermission, isPrincipal, Perm, User } from '../src/index'
import { randomUUID } from 'node:crypto'

const schoolIds = [randomUUID(), randomUUID(), randomUUID()]

const user: User = {
userAccountId: '',
ssn: '',
schools: schoolIds.map(schoolId => ({ schoolId, permissions: [], principal: false, roles: [] })),
roles: []
}

describe('Permissions', () => {
const testCases = <{ permissions: Perm[]; principal?: boolean; requiredPermission: Perm; expectedResult: boolean }[]>[
{
permissions: [],
requiredPermission: '*',
expectedResult: false
},
{
permissions: [],
requiredPermission: 'application-dyslexia',
expectedResult: false
},

{
permissions: [],
requiredPermission: 'application-illness',
expectedResult: false
},

{
permissions: ['application-dyslexia', 'foo'],
requiredPermission: '*',
expectedResult: true
},
{
permissions: ['application-dyslexia'],
requiredPermission: 'application-illness',
expectedResult: false
},
{
permissions: ['application-dyslexia'],
requiredPermission: 'application-dyslexia',
expectedResult: true
},

{
permissions: [],
principal: true,
requiredPermission: '*',
expectedResult: true
},
{
permissions: [],
principal: true,
requiredPermission: 'application-foreign',
expectedResult: true
}
]

test('check variety of permissions', () => {
for (const testCase of testCases) {
const testCaseUser = modifyUser(user, 0, testCase.permissions, testCase.principal)
const message = `${testCase.expectedResult ? 'Accessible' : 'Inaccessible'} (user permissions: [${testCase.permissions.toString()}], principal: ${testCase.principal ?? false}, required permission: ${testCase.requiredPermission})`
assert.equal(hasPermission(testCaseUser, testCase.requiredPermission), testCase.expectedResult, message)
}
})

test('check if principal', () => {
const testCaseUser = modifyUser(user, 0, [], true)
assert.equal(isPrincipal(testCaseUser), true)
})

test('check if principal in given school', () => {
const testCaseUser = modifyUser(user, 0, [], true)
assert.equal(isPrincipal(testCaseUser, { schoolId: user.schools[0].schoolId }), true)
assert.equal(isPrincipal(testCaseUser, { schoolId: user.schools[1].schoolId }), false)
})

test('check if a user has a permission in given school', () => {
const testCaseUser = modifyUser(user, 0, ['observations'])
assert.equal(hasPermission(testCaseUser, 'observations', { schoolId: testCaseUser.schools[0].schoolId }), true)
assert.equal(hasPermission(testCaseUser, 'observations', { schoolId: testCaseUser.schools[1].schoolId }), false)
const testCasePrincipal = modifyUser(user, 0, [], true)
assert.equal(hasPermission(testCasePrincipal, 'observations', { schoolId: testCaseUser.schools[0].schoolId }), true)
assert.equal(
hasPermission(testCasePrincipal, 'observations', { schoolId: testCaseUser.schools[1].schoolId }),
false
)
})

test('check if a user has a permission in given school ignoring principal right', () => {
const testCaseUser = modifyUser(user, 0, [], true)
assert.equal(
hasPermission(testCaseUser, 'observations', {
schoolId: testCaseUser.schools[0].schoolId,
ignorePrincipalRight: true
}),
false
)
assert.equal(
hasPermission(testCaseUser, 'observations', {
schoolId: testCaseUser.schools[1].schoolId,
ignorePrincipalRight: true
}),
false
)
})

test('check if a user has an application permission', () => {
const testCaseUser = modifyUser(user, 0, ['application-illness'])
assert.equal(hasApplicationPermission(testCaseUser), true)
assert.equal(hasApplicationPermission(testCaseUser, { schoolId: testCaseUser.schools[1].schoolId }), false)
const testCasePrincipal = modifyUser(user, 0, [], true)
assert.equal(hasApplicationPermission(testCasePrincipal), true)
assert.equal(hasApplicationPermission(testCasePrincipal, { schoolId: testCaseUser.schools[1].schoolId }), false)
})
})

function modifyUser(user: User, schoolIindex: number, permissions: Perm[], principal?: boolean): User {
return {
...user,
schools: user.schools.map((school, index) =>
index === schoolIindex ? { ...school, principal: principal ?? false, permissions } : school
)
}
}
3 changes: 3 additions & 0 deletions packages/user-management/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,5 +24,8 @@
],
"publishConfig": {
"access": "public"
},
"dependencies": {
"zod": "^4.4.3"
}
}
63 changes: 4 additions & 59 deletions packages/user-management/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,59 +1,4 @@
/** Table of user permission strings */
export const perm = {
specialArrangements: 'special-arrangements',
observations: 'observations',
registrations: { send: 'registrations::send' },
ktpConnection: { open: 'ktp-connection::open' },
answerPackages: { send: 'answer-packages::send' },

resultList: {
view: 'result-list::view',
viewWithSsns: 'result-list::view-with-ssns'
},

notification: {
deniedParticipation: {
all: 'notification-denied-participation',
notify: 'notification-denied-participation::notify',
viewDecision: 'notification-denied-participation::view-decision'
},
includedExams: {
all: 'notification-included-exams',
viewDecision: 'notification-included-exams::view-decision'
}
},

application: {
technicalFault: 'application-technical-fault',
dyslexia: {
all: 'application-dyslexia',
viewDecision: 'application-dyslexia::view-decision'
},
foreign: {
all: 'application-foreign',
viewDecision: 'application-foreign::view-decision'
},
illness: {
all: 'application-illness',
viewDecision: 'application-illness::view-decision'
},
nullify: {
all: 'application-nullify-registration',
viewDecision: 'application-nullify-registration::view-decision'
},
change: {
all: 'application-change-registration',
viewDecision: 'application-change-registration::view-decision'
},
late: {
all: 'application-late-registration',
viewDecision: 'application-late-registration::view-decision'
}
}
} as const

/** Helper to get the union of the types of the leaves of a nested object */
type Leaves<Tree, Node = Tree[keyof Tree]> = Node extends Record<string, any> ? Leaves<Node> : Node

/** A user permission string */
export type Perm = Leaves<typeof perm>
export * from './user-checks'
export * from './validations'
export * from './permissions'
export * from './user-types'
95 changes: 95 additions & 0 deletions packages/user-management/src/permissions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
/** Table of user permission strings */
export const perm = {
any: '*',
specialArrangements: 'special-arrangements',
observations: 'observations',
registrations: { send: 'registrations::send' },
ktpConnection: { open: 'ktp-connection::open' },
answerPackages: { send: 'answer-packages::send' },

resultList: {
view: 'result-list::view',
viewWithSsns: 'result-list::view-with-ssns'
},

notification: {
deniedParticipation: {
all: 'notification-denied-participation',
notify: 'notification-denied-participation::notify',
viewDecision: 'notification-denied-participation::view-decision'
},
includedExams: {
all: 'notification-included-exams',
viewDecision: 'notification-included-exams::view-decision'
}
},

application: {
technicalFault: {
all: 'application-technical-fault'
},
dyslexia: {
all: 'application-dyslexia',
viewDecision: 'application-dyslexia::view-decision'
},
foreign: {
all: 'application-foreign',
viewDecision: 'application-foreign::view-decision'
},
illness: {
all: 'application-illness',
viewDecision: 'application-illness::view-decision'
},
nullify: {
all: 'application-nullify-registration',
viewDecision: 'application-nullify-registration::view-decision'
},
change: {
all: 'application-change-registration',
viewDecision: 'application-change-registration::view-decision'
},
late: {
all: 'application-late-registration',
viewDecision: 'application-late-registration::view-decision'
}
},

grading: {
censor: 'grading-censor'
}
} as const

/** Helper to get the union of the types of the leaves of a nested object */
type Leaves<Tree, Node = Tree[keyof Tree]> = Node extends Record<string, unknown> ? Leaves<Node> : Node

/** A user permission string */
export type Perm = Leaves<typeof perm>

type AllPerms<T> = T extends { all: infer A } ? A : T extends Record<string, unknown> ? AllPerms<T[keyof T]> : never

export type AppPerm = AllPerms<typeof perm.application | typeof perm.notification>

function collectApplicationPermissions(obj: Record<string, unknown>): Perm[] {
const result: Perm[] = []

for (const value of Object.values(obj)) {
if (value && typeof value === 'object') {
if ('all' in value) {
result.push(value.all as Perm)
}

result.push(...collectApplicationPermissions(value as Record<string, unknown>))
}
}
return result
}

export const applicationPermissions = [
...collectApplicationPermissions(perm.application),
...collectApplicationPermissions(perm.notification)
]

export type PermissionOptions = {
schoolId?: string
ignorePrincipalRight?: boolean
}
36 changes: 36 additions & 0 deletions packages/user-management/src/user-checks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { applicationPermissions, AppPerm, perm, Perm, PermissionOptions, User, UserSchool } from './index'

function userSchools(user: User, options?: PermissionOptions): UserSchool[] {
return options?.schoolId ? user.schools.filter(school => school.schoolId === options.schoolId) : user.schools
}

export function userPermissions(user: User, options?: PermissionOptions): Perm[] {
return userSchools(user, options).flatMap(school => school.permissions) ?? []
}

export function isPrincipal(user: User, options?: PermissionOptions): boolean {
return userSchools(user, options).find(school => school.principal) !== undefined
}

export function hasPermission(user: User, requiredPermission: Perm, options?: PermissionOptions): boolean {
const principal = isPrincipal(user, options)
if (principal && !options?.ignorePrincipalRight) {
return true
}
const permissions = userPermissions(user, options)
if (requiredPermission === perm.any) {
return permissions.length > 0 // if required permission is '*', any user permission will do
}
return permissions.includes(requiredPermission)
}

function isApplicationPermission(permission: Perm): permission is AppPerm {
return applicationPermissions.includes(permission as AppPerm)
}

export function hasApplicationPermission(user: User, options?: PermissionOptions): boolean {
return (
isPrincipal(user, options) ||
userPermissions(user, options).find(permission => isApplicationPermission(permission)) !== undefined
)
}
Loading