@elevasis/core 0.29.0 → 0.30.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/auth/index.d.ts +5289 -0
- package/dist/auth/index.js +595 -0
- package/dist/test-utils/index.d.ts +20 -0
- package/package.json +5 -1
- package/src/__tests__/publish.test.ts +8 -7
- package/src/auth/__tests__/access-key-coverage.test.ts +42 -0
- package/src/auth/__tests__/access-key-scan.ts +117 -0
- package/src/auth/__tests__/access-keys.test.ts +81 -0
- package/src/auth/__tests__/access-model.test.ts +257 -0
- package/src/auth/__tests__/access-test-fixtures.ts +50 -0
- package/src/auth/__tests__/key-catalog-drift.test.ts +33 -0
- package/src/auth/__tests__/platform-admin-bypass-parity.test.ts +67 -0
- package/src/auth/access-keys.ts +173 -0
- package/src/auth/access-model.ts +185 -0
- package/src/auth/index.ts +6 -2
- package/src/auth/multi-tenancy/memberships/membership.ts +2 -4
- package/src/auth/multi-tenancy/permissions.ts +1 -1
- package/src/auth/multi-tenancy/types.ts +3 -12
- package/src/reference/glossary.md +8 -6
- package/src/supabase/database.types.ts +10 -0
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import { AccessKeys, deriveAccessKeyCatalog, findAccessCatalogEntry, normalizeAccessKey } from '../access-keys'
|
|
4
|
+
import { accessTestOrganizationModel } from './access-test-fixtures'
|
|
5
|
+
import { scanAccessKeyUsages } from './access-key-scan'
|
|
6
|
+
|
|
7
|
+
const INTENTIONALLY_UNCONSUMED_ACCESS_KEYS = new Set([
|
|
8
|
+
'secretsManage',
|
|
9
|
+
'operationsManage',
|
|
10
|
+
'acquisitionManage',
|
|
11
|
+
'projectsManage',
|
|
12
|
+
'clientsManage',
|
|
13
|
+
'operationsRecentExecutions'
|
|
14
|
+
])
|
|
15
|
+
|
|
16
|
+
describe('access key coverage', () => {
|
|
17
|
+
it('keeps every runtime consumer key present in the catalog', () => {
|
|
18
|
+
const catalog = deriveAccessKeyCatalog(accessTestOrganizationModel)
|
|
19
|
+
const usages = scanAccessKeyUsages()
|
|
20
|
+
const consumerKeys = [
|
|
21
|
+
...usages.stringLiterals.map((usage) => ({ file: usage.file, key: normalizeAccessKey(usage.key) })),
|
|
22
|
+
...usages.accessKeySymbols.map((usage) => ({
|
|
23
|
+
file: usage.file,
|
|
24
|
+
key: AccessKeys[usage.symbol as keyof typeof AccessKeys]
|
|
25
|
+
}))
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
const missingCatalogEntries = consumerKeys
|
|
29
|
+
.filter((usage) => findAccessCatalogEntry(catalog, usage.key) === undefined)
|
|
30
|
+
.map((usage) => `${usage.file}: ${usage.key.systemPath}:${usage.key.action}`)
|
|
31
|
+
|
|
32
|
+
expect(missingCatalogEntries).toEqual([])
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('keeps exported static AccessKeys either consumed or explicitly reserved', () => {
|
|
36
|
+
const usages = scanAccessKeyUsages()
|
|
37
|
+
const consumedSymbols = new Set(usages.accessKeySymbols.map((usage) => usage.symbol))
|
|
38
|
+
const unconsumedSymbols = Object.keys(AccessKeys).filter((symbol) => !consumedSymbols.has(symbol))
|
|
39
|
+
|
|
40
|
+
expect(unconsumedSymbols).toEqual([...INTENTIONALLY_UNCONSUMED_ACCESS_KEYS])
|
|
41
|
+
})
|
|
42
|
+
})
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync } from 'node:fs'
|
|
2
|
+
import { dirname, join, relative } from 'node:path'
|
|
3
|
+
import { fileURLToPath } from 'node:url'
|
|
4
|
+
|
|
5
|
+
import type { AccessKeyInput } from '../access-keys'
|
|
6
|
+
|
|
7
|
+
export interface AccessKeyUsage {
|
|
8
|
+
file: string
|
|
9
|
+
key: AccessKeyInput
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface AccessKeySymbolUsage {
|
|
13
|
+
file: string
|
|
14
|
+
symbol: string
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const repoRoot = findRepoRoot(dirname(fileURLToPath(import.meta.url)))
|
|
18
|
+
|
|
19
|
+
const RUNTIME_SOURCE_ROOTS = [
|
|
20
|
+
join(repoRoot, 'apps', 'command-center', 'src'),
|
|
21
|
+
join(repoRoot, 'packages', 'ui', 'src'),
|
|
22
|
+
join(repoRoot, 'apps', 'api', 'src'),
|
|
23
|
+
join(repoRoot, 'external', '_template', 'ui', 'src')
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
const STRING_LITERAL_PATTERNS = [
|
|
27
|
+
/<AccessGuard\b[\s\S]*?\baccessKey\s*=\s*["']([^"']+)["']/g,
|
|
28
|
+
/<AccessGuard\b[\s\S]*?\baccessKey\s*=\s*\{\s*["']([^"']+)["']\s*\}/g,
|
|
29
|
+
/\buseAccess\(\s*["']([^"']+)["']\s*\)/g,
|
|
30
|
+
/\brequireAccess\(\s*["']([^"']+)["']\s*[,)]/g
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
const ACCESS_KEYS_PATTERN = /\bAccessKeys\.([A-Za-z_$][A-Za-z0-9_$]*)\b/g
|
|
34
|
+
|
|
35
|
+
function findRepoRoot(startDirectory: string): string {
|
|
36
|
+
let current = startDirectory
|
|
37
|
+
|
|
38
|
+
while (current !== dirname(current)) {
|
|
39
|
+
if (existsSync(join(current, 'pnpm-workspace.yaml'))) return current
|
|
40
|
+
current = dirname(current)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
throw new Error('Could not find monorepo root from access-key scan test')
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function isRuntimeSourceFile(file: string): boolean {
|
|
47
|
+
if (!/\.(ts|tsx)$/.test(file)) return false
|
|
48
|
+
if (file.includes(`${join('', '__tests__').slice(1)}`)) return false
|
|
49
|
+
if (file.includes('__tests__')) return false
|
|
50
|
+
if (/\.(test|spec)\.(ts|tsx)$/.test(file)) return false
|
|
51
|
+
return !file.endsWith('routeTree.gen.ts')
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function listRuntimeSourceFiles(directory: string): string[] {
|
|
55
|
+
if (!existsSync(directory)) return []
|
|
56
|
+
|
|
57
|
+
const entries = readdirSync(directory, { withFileTypes: true })
|
|
58
|
+
const files: string[] = []
|
|
59
|
+
|
|
60
|
+
for (const entry of entries) {
|
|
61
|
+
const entryPath = join(directory, entry.name)
|
|
62
|
+
|
|
63
|
+
if (entry.isDirectory()) {
|
|
64
|
+
if (['node_modules', 'dist', '.next', 'coverage'].includes(entry.name)) continue
|
|
65
|
+
files.push(...listRuntimeSourceFiles(entryPath))
|
|
66
|
+
continue
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (isRuntimeSourceFile(entryPath)) files.push(entryPath)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return files
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function relativePath(file: string): string {
|
|
76
|
+
return relative(repoRoot, file).replaceAll('\\', '/')
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function collectMatches<T>(
|
|
80
|
+
content: string,
|
|
81
|
+
pattern: RegExp,
|
|
82
|
+
build: (match: RegExpExecArray) => T
|
|
83
|
+
): T[] {
|
|
84
|
+
const matches: T[] = []
|
|
85
|
+
pattern.lastIndex = 0
|
|
86
|
+
|
|
87
|
+
for (let match = pattern.exec(content); match !== null; match = pattern.exec(content)) {
|
|
88
|
+
matches.push(build(match))
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return matches
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function scanAccessKeyUsages(): {
|
|
95
|
+
stringLiterals: AccessKeyUsage[]
|
|
96
|
+
accessKeySymbols: AccessKeySymbolUsage[]
|
|
97
|
+
} {
|
|
98
|
+
const stringLiterals: AccessKeyUsage[] = []
|
|
99
|
+
const accessKeySymbols: AccessKeySymbolUsage[] = []
|
|
100
|
+
|
|
101
|
+
for (const sourceRoot of RUNTIME_SOURCE_ROOTS) {
|
|
102
|
+
for (const file of listRuntimeSourceFiles(sourceRoot)) {
|
|
103
|
+
const content = readFileSync(file, 'utf8')
|
|
104
|
+
const path = relativePath(file)
|
|
105
|
+
|
|
106
|
+
for (const pattern of STRING_LITERAL_PATTERNS) {
|
|
107
|
+
stringLiterals.push(...collectMatches(content, pattern, (match) => ({ file: path, key: match[1] })))
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
accessKeySymbols.push(
|
|
111
|
+
...collectMatches(content, ACCESS_KEYS_PATTERN, (match) => ({ file: path, symbol: match[1] }))
|
|
112
|
+
)
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return { stringLiterals, accessKeySymbols }
|
|
117
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import {
|
|
3
|
+
AccessKeys,
|
|
4
|
+
DIAGNOSTIC_VIEW_ACCESS_KEYS,
|
|
5
|
+
PLATFORM_ADMIN_ACCESS_KEY,
|
|
6
|
+
deriveAccessKeyCatalog,
|
|
7
|
+
findAccessCatalogEntry,
|
|
8
|
+
listAccessKeys,
|
|
9
|
+
normalizeAccessKey
|
|
10
|
+
} from '../access-keys'
|
|
11
|
+
import type { OrganizationModel } from '../../organization-model/types'
|
|
12
|
+
|
|
13
|
+
const model = {
|
|
14
|
+
systems: {
|
|
15
|
+
sales: {
|
|
16
|
+
id: 'sales',
|
|
17
|
+
label: 'Sales',
|
|
18
|
+
lifecycle: 'active',
|
|
19
|
+
order: 10,
|
|
20
|
+
systems: {
|
|
21
|
+
crm: {
|
|
22
|
+
id: 'crm',
|
|
23
|
+
label: 'CRM',
|
|
24
|
+
lifecycle: 'active',
|
|
25
|
+
order: 10
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
},
|
|
29
|
+
operations: {
|
|
30
|
+
id: 'operations',
|
|
31
|
+
label: 'Operations',
|
|
32
|
+
lifecycle: 'active',
|
|
33
|
+
order: 20
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
} as OrganizationModel
|
|
37
|
+
|
|
38
|
+
describe('access keys', () => {
|
|
39
|
+
it('normalizes string shorthand to view access keys', () => {
|
|
40
|
+
expect(normalizeAccessKey('sales.crm')).toEqual({ systemPath: 'sales.crm', action: 'view' })
|
|
41
|
+
expect(normalizeAccessKey({ systemPath: 'sales.crm' })).toEqual({ systemPath: 'sales.crm', action: 'view' })
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('normalizes platformAdmin string shorthand to the platform special-case key', () => {
|
|
45
|
+
expect(normalizeAccessKey('platformAdmin')).toEqual(AccessKeys.platformAdmin)
|
|
46
|
+
expect(normalizeAccessKey('platformAdmin').systemPath).toBe(PLATFORM_ADMIN_ACCESS_KEY)
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('derives view and manage access keys from nested Organization Model systems', () => {
|
|
50
|
+
const catalog = deriveAccessKeyCatalog(model)
|
|
51
|
+
const keys = listAccessKeys(catalog)
|
|
52
|
+
|
|
53
|
+
expect(keys).toContainEqual({ systemPath: 'sales', action: 'view' })
|
|
54
|
+
expect(keys).toContainEqual({ systemPath: 'sales.crm', action: 'view' })
|
|
55
|
+
expect(keys).toContainEqual({ systemPath: 'sales.crm', action: 'manage' })
|
|
56
|
+
expect(keys).toContainEqual({ systemPath: 'operations', action: 'manage' })
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('includes diagnostic allowlist keys and platformAdmin in the catalog', () => {
|
|
60
|
+
const catalog = deriveAccessKeyCatalog(model)
|
|
61
|
+
|
|
62
|
+
for (const systemPath of DIAGNOSTIC_VIEW_ACCESS_KEYS) {
|
|
63
|
+
expect(findAccessCatalogEntry(catalog, { systemPath, action: 'view' })?.source).toBe('diagnostic')
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
expect(findAccessCatalogEntry(catalog, AccessKeys.platformAdmin)?.source).toBe('platform')
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('includes permission-only access keys with their legacy role-permission mapping', () => {
|
|
70
|
+
const catalog = deriveAccessKeyCatalog(model)
|
|
71
|
+
|
|
72
|
+
expect(findAccessCatalogEntry(catalog, AccessKeys.organizationManage)).toMatchObject({
|
|
73
|
+
source: 'permission',
|
|
74
|
+
rolePermission: 'org.manage'
|
|
75
|
+
})
|
|
76
|
+
expect(findAccessCatalogEntry(catalog, AccessKeys.membersManage)).toMatchObject({
|
|
77
|
+
source: 'permission',
|
|
78
|
+
rolePermission: 'members.manage'
|
|
79
|
+
})
|
|
80
|
+
})
|
|
81
|
+
})
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { AccessKeys, deriveAccessKeyCatalog } from '../access-keys'
|
|
3
|
+
import { checkAccess } from '../access-model'
|
|
4
|
+
import type { AccessContext } from '../access-model'
|
|
5
|
+
import type { OrganizationModel } from '../../organization-model/types'
|
|
6
|
+
|
|
7
|
+
const organizationModel = {
|
|
8
|
+
systems: {
|
|
9
|
+
sales: {
|
|
10
|
+
id: 'sales',
|
|
11
|
+
label: 'Sales',
|
|
12
|
+
lifecycle: 'active',
|
|
13
|
+
order: 10,
|
|
14
|
+
systems: {
|
|
15
|
+
crm: {
|
|
16
|
+
id: 'crm',
|
|
17
|
+
label: 'CRM',
|
|
18
|
+
lifecycle: 'active',
|
|
19
|
+
order: 10
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
},
|
|
23
|
+
operations: {
|
|
24
|
+
id: 'operations',
|
|
25
|
+
label: 'Operations',
|
|
26
|
+
lifecycle: 'beta',
|
|
27
|
+
order: 20
|
|
28
|
+
},
|
|
29
|
+
drafts: {
|
|
30
|
+
id: 'drafts',
|
|
31
|
+
label: 'Drafts',
|
|
32
|
+
lifecycle: 'draft',
|
|
33
|
+
order: 30
|
|
34
|
+
},
|
|
35
|
+
retired: {
|
|
36
|
+
id: 'retired',
|
|
37
|
+
label: 'Retired',
|
|
38
|
+
lifecycle: 'deprecated',
|
|
39
|
+
order: 40
|
|
40
|
+
},
|
|
41
|
+
missingLifecycle: {
|
|
42
|
+
id: 'missingLifecycle',
|
|
43
|
+
label: 'Missing Lifecycle',
|
|
44
|
+
order: 50
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
} as OrganizationModel
|
|
48
|
+
|
|
49
|
+
const baseContext: AccessContext = {
|
|
50
|
+
organizationId: 'org-1',
|
|
51
|
+
organizationModel,
|
|
52
|
+
membership: {
|
|
53
|
+
id: 'membership-1',
|
|
54
|
+
organizationId: 'org-1',
|
|
55
|
+
effectivePermissions: ['sales.crm.manage']
|
|
56
|
+
},
|
|
57
|
+
profile: {
|
|
58
|
+
id: 'user-1',
|
|
59
|
+
isPlatformAdmin: false
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function context(overrides: Partial<AccessContext> = {}): AccessContext {
|
|
64
|
+
return { ...baseContext, ...overrides }
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
describe('checkAccess', () => {
|
|
68
|
+
it('allows active system view access by lifecycle alone', () => {
|
|
69
|
+
expect(checkAccess('sales.crm', context())).toEqual({
|
|
70
|
+
allowed: true,
|
|
71
|
+
restrictedBy: null,
|
|
72
|
+
reason: 'allowed'
|
|
73
|
+
})
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it('denies unknown keys through catalog validation', () => {
|
|
77
|
+
expect(checkAccess('unknown.system', context())).toEqual({
|
|
78
|
+
allowed: false,
|
|
79
|
+
restrictedBy: 'catalog',
|
|
80
|
+
reason: 'unknown-access-key'
|
|
81
|
+
})
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it('denies missing membership before evaluating lifecycle or permissions', () => {
|
|
85
|
+
expect(checkAccess('sales.crm', context({ membership: null }))).toEqual({
|
|
86
|
+
allowed: false,
|
|
87
|
+
restrictedBy: 'membership',
|
|
88
|
+
reason: 'missing-membership'
|
|
89
|
+
})
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
it('denies organization mismatches', () => {
|
|
93
|
+
expect(
|
|
94
|
+
checkAccess(
|
|
95
|
+
'sales.crm',
|
|
96
|
+
context({
|
|
97
|
+
membership: { id: 'membership-2', organizationId: 'org-2', effectivePermissions: ['sales.crm.manage'] }
|
|
98
|
+
})
|
|
99
|
+
)
|
|
100
|
+
).toEqual({
|
|
101
|
+
allowed: false,
|
|
102
|
+
restrictedBy: 'membership',
|
|
103
|
+
reason: 'organization-mismatch'
|
|
104
|
+
})
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it('denies draft, deprecated, archived, and missing lifecycle systems', () => {
|
|
108
|
+
const archivedModel = {
|
|
109
|
+
systems: {
|
|
110
|
+
archived: {
|
|
111
|
+
id: 'archived',
|
|
112
|
+
label: 'Archived',
|
|
113
|
+
lifecycle: 'archived',
|
|
114
|
+
order: 10
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
} as OrganizationModel
|
|
118
|
+
|
|
119
|
+
expect(checkAccess('drafts', context())).toMatchObject({
|
|
120
|
+
allowed: false,
|
|
121
|
+
restrictedBy: 'system-lifecycle',
|
|
122
|
+
reason: 'system-not-active'
|
|
123
|
+
})
|
|
124
|
+
expect(checkAccess('retired', context())).toMatchObject({
|
|
125
|
+
allowed: false,
|
|
126
|
+
restrictedBy: 'system-lifecycle',
|
|
127
|
+
reason: 'system-not-active'
|
|
128
|
+
})
|
|
129
|
+
expect(checkAccess('missingLifecycle', context())).toMatchObject({
|
|
130
|
+
allowed: false,
|
|
131
|
+
restrictedBy: 'system-lifecycle',
|
|
132
|
+
reason: 'system-not-active'
|
|
133
|
+
})
|
|
134
|
+
expect(checkAccess('archived', context({ organizationModel: archivedModel }))).toMatchObject({
|
|
135
|
+
allowed: false,
|
|
136
|
+
restrictedBy: 'system-lifecycle',
|
|
137
|
+
reason: 'system-not-active'
|
|
138
|
+
})
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
it('allows beta systems only when beta access or development mode is enabled', () => {
|
|
142
|
+
expect(checkAccess('operations', context())).toEqual({
|
|
143
|
+
allowed: false,
|
|
144
|
+
restrictedBy: 'system-lifecycle',
|
|
145
|
+
reason: 'system-not-active'
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
expect(checkAccess('operations', context({ betaAccessEnabled: true }))).toEqual({
|
|
149
|
+
allowed: true,
|
|
150
|
+
restrictedBy: null,
|
|
151
|
+
reason: 'allowed'
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
expect(checkAccess('operations', context({ isDevelopment: true }))).toEqual({
|
|
155
|
+
allowed: true,
|
|
156
|
+
restrictedBy: null,
|
|
157
|
+
reason: 'allowed'
|
|
158
|
+
})
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
it('requires matching role permission for manage when permissions are supplied', () => {
|
|
162
|
+
expect(checkAccess({ systemPath: 'sales.crm', action: 'manage' }, context())).toEqual({
|
|
163
|
+
allowed: true,
|
|
164
|
+
restrictedBy: null,
|
|
165
|
+
reason: 'allowed'
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
expect(
|
|
169
|
+
checkAccess(
|
|
170
|
+
{ systemPath: 'sales.crm', action: 'manage' },
|
|
171
|
+
context({
|
|
172
|
+
membership: { id: 'membership-1', organizationId: 'org-1', effectivePermissions: [] }
|
|
173
|
+
})
|
|
174
|
+
)
|
|
175
|
+
).toEqual({
|
|
176
|
+
allowed: false,
|
|
177
|
+
restrictedBy: 'role-permission',
|
|
178
|
+
reason: 'role-permission-denied'
|
|
179
|
+
})
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
it('requires explicit role permissions for permission-only access keys', () => {
|
|
183
|
+
expect(
|
|
184
|
+
checkAccess(
|
|
185
|
+
AccessKeys.organizationManage,
|
|
186
|
+
context({
|
|
187
|
+
membership: { id: 'membership-1', organizationId: 'org-1', effectivePermissions: ['org.manage'] }
|
|
188
|
+
})
|
|
189
|
+
)
|
|
190
|
+
).toEqual({
|
|
191
|
+
allowed: true,
|
|
192
|
+
restrictedBy: null,
|
|
193
|
+
reason: 'allowed'
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
expect(checkAccess(AccessKeys.organizationManage, context())).toEqual({
|
|
197
|
+
allowed: false,
|
|
198
|
+
restrictedBy: 'role-permission',
|
|
199
|
+
reason: 'role-permission-denied'
|
|
200
|
+
})
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
it('checks diagnostic keys against the runtime diagnostic allowlist', () => {
|
|
204
|
+
expect(checkAccess(AccessKeys.operationsOverview, context())).toEqual({
|
|
205
|
+
allowed: false,
|
|
206
|
+
restrictedBy: 'diagnostic-allowlist',
|
|
207
|
+
reason: 'diagnostic-key-not-allowed'
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
expect(
|
|
211
|
+
checkAccess(
|
|
212
|
+
AccessKeys.operationsOverview,
|
|
213
|
+
context({ diagnosticAllowlist: [AccessKeys.operationsOverview.systemPath] })
|
|
214
|
+
)
|
|
215
|
+
).toEqual({
|
|
216
|
+
allowed: true,
|
|
217
|
+
restrictedBy: null,
|
|
218
|
+
reason: 'allowed'
|
|
219
|
+
})
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
it('keeps diagnostic catalog validation separate from runtime diagnostic allowlist', () => {
|
|
223
|
+
const accessCatalog = deriveAccessKeyCatalog(organizationModel, { diagnosticKeys: ['diagnostic.custom'] })
|
|
224
|
+
|
|
225
|
+
expect(
|
|
226
|
+
checkAccess('diagnostic.custom', context({ accessCatalog, diagnosticAllowlist: ['diagnostic.custom'] }))
|
|
227
|
+
).toEqual({
|
|
228
|
+
allowed: true,
|
|
229
|
+
restrictedBy: null,
|
|
230
|
+
reason: 'allowed'
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
expect(checkAccess('diagnostic.not-cataloged', context({ diagnosticAllowlist: ['diagnostic.not-cataloged'] }))).toEqual({
|
|
234
|
+
allowed: false,
|
|
235
|
+
restrictedBy: 'catalog',
|
|
236
|
+
reason: 'unknown-access-key'
|
|
237
|
+
})
|
|
238
|
+
})
|
|
239
|
+
|
|
240
|
+
it('centralizes platform-admin bypass with the platform-admin-bypass reason', () => {
|
|
241
|
+
expect(
|
|
242
|
+
checkAccess('unknown.system', context({ profile: { id: 'admin', isPlatformAdmin: true }, membership: null }))
|
|
243
|
+
).toEqual({
|
|
244
|
+
allowed: true,
|
|
245
|
+
restrictedBy: null,
|
|
246
|
+
reason: 'platform-admin-bypass'
|
|
247
|
+
})
|
|
248
|
+
})
|
|
249
|
+
|
|
250
|
+
it('denies platformAdmin special-case for non-platform admins', () => {
|
|
251
|
+
expect(checkAccess('platformAdmin', context())).toEqual({
|
|
252
|
+
allowed: false,
|
|
253
|
+
restrictedBy: 'role-permission',
|
|
254
|
+
reason: 'role-permission-denied'
|
|
255
|
+
})
|
|
256
|
+
})
|
|
257
|
+
})
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { OrganizationModel } from '../../organization-model/types'
|
|
2
|
+
|
|
3
|
+
export const accessTestOrganizationModel = {
|
|
4
|
+
systems: {
|
|
5
|
+
platform: {
|
|
6
|
+
id: 'platform',
|
|
7
|
+
label: 'Platform',
|
|
8
|
+
lifecycle: 'active',
|
|
9
|
+
order: 10
|
|
10
|
+
},
|
|
11
|
+
sales: {
|
|
12
|
+
id: 'sales',
|
|
13
|
+
label: 'Sales',
|
|
14
|
+
lifecycle: 'active',
|
|
15
|
+
order: 20,
|
|
16
|
+
systems: {
|
|
17
|
+
crm: {
|
|
18
|
+
id: 'crm',
|
|
19
|
+
label: 'CRM',
|
|
20
|
+
lifecycle: 'active',
|
|
21
|
+
order: 10
|
|
22
|
+
},
|
|
23
|
+
'lead-gen': {
|
|
24
|
+
id: 'lead-gen',
|
|
25
|
+
label: 'Lead Gen',
|
|
26
|
+
lifecycle: 'active',
|
|
27
|
+
order: 20
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
projects: {
|
|
32
|
+
id: 'projects',
|
|
33
|
+
label: 'Projects',
|
|
34
|
+
lifecycle: 'active',
|
|
35
|
+
order: 30
|
|
36
|
+
},
|
|
37
|
+
clients: {
|
|
38
|
+
id: 'clients',
|
|
39
|
+
label: 'Clients',
|
|
40
|
+
lifecycle: 'active',
|
|
41
|
+
order: 40
|
|
42
|
+
},
|
|
43
|
+
seo: {
|
|
44
|
+
id: 'seo',
|
|
45
|
+
label: 'SEO',
|
|
46
|
+
lifecycle: 'active',
|
|
47
|
+
order: 50
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
} as unknown as OrganizationModel
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import { AccessKeys, deriveAccessKeyCatalog, findAccessCatalogEntry, normalizeAccessKey } from '../access-keys'
|
|
4
|
+
import { accessTestOrganizationModel } from './access-test-fixtures'
|
|
5
|
+
import { scanAccessKeyUsages } from './access-key-scan'
|
|
6
|
+
|
|
7
|
+
describe('access key catalog drift', () => {
|
|
8
|
+
it('keeps runtime literal access keys in the derived catalog', () => {
|
|
9
|
+
const catalog = deriveAccessKeyCatalog(accessTestOrganizationModel)
|
|
10
|
+
const usages = scanAccessKeyUsages()
|
|
11
|
+
|
|
12
|
+
const unknownLiterals = usages.stringLiterals
|
|
13
|
+
.map((usage) => {
|
|
14
|
+
const key = normalizeAccessKey(usage.key)
|
|
15
|
+
return findAccessCatalogEntry(catalog, key) === undefined
|
|
16
|
+
? `${usage.file}: ${JSON.stringify(usage.key)}`
|
|
17
|
+
: null
|
|
18
|
+
})
|
|
19
|
+
.filter((usage): usage is string => usage !== null)
|
|
20
|
+
|
|
21
|
+
expect(unknownLiterals).toEqual([])
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('keeps runtime AccessKeys references backed by exported symbols', () => {
|
|
25
|
+
const usages = scanAccessKeyUsages()
|
|
26
|
+
const exportedSymbols = new Set(Object.keys(AccessKeys))
|
|
27
|
+
const unknownSymbols = usages.accessKeySymbols
|
|
28
|
+
.filter((usage) => !exportedSymbols.has(usage.symbol))
|
|
29
|
+
.map((usage) => `${usage.file}: AccessKeys.${usage.symbol}`)
|
|
30
|
+
|
|
31
|
+
expect(unknownSymbols).toEqual([])
|
|
32
|
+
})
|
|
33
|
+
})
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import { AccessKeys } from '../access-keys'
|
|
4
|
+
import { checkAccess, createAccessModel, type AccessContext } from '../access-model'
|
|
5
|
+
import { accessTestOrganizationModel } from './access-test-fixtures'
|
|
6
|
+
|
|
7
|
+
const bypassAnswer = {
|
|
8
|
+
allowed: true,
|
|
9
|
+
restrictedBy: null,
|
|
10
|
+
reason: 'platform-admin-bypass'
|
|
11
|
+
} as const
|
|
12
|
+
|
|
13
|
+
const baseContext: AccessContext = {
|
|
14
|
+
organizationId: 'org-1',
|
|
15
|
+
organizationModel: accessTestOrganizationModel,
|
|
16
|
+
membership: {
|
|
17
|
+
id: 'membership-1',
|
|
18
|
+
organizationId: 'org-1',
|
|
19
|
+
effectivePermissions: []
|
|
20
|
+
},
|
|
21
|
+
profile: {
|
|
22
|
+
id: 'user-1',
|
|
23
|
+
isPlatformAdmin: false
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
describe('platform admin bypass parity', () => {
|
|
28
|
+
it('short-circuits unknown keys before catalog and membership checks', () => {
|
|
29
|
+
expect(
|
|
30
|
+
checkAccess('unknown.system', {
|
|
31
|
+
...baseContext,
|
|
32
|
+
membership: null,
|
|
33
|
+
profile: { id: 'admin', isPlatformAdmin: true }
|
|
34
|
+
})
|
|
35
|
+
).toEqual(bypassAnswer)
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('supports both camelCase and database-shaped platform-admin profile flags', () => {
|
|
39
|
+
expect(
|
|
40
|
+
checkAccess('unknown.system', {
|
|
41
|
+
...baseContext,
|
|
42
|
+
membership: null,
|
|
43
|
+
profile: { id: 'admin', is_platform_admin: true }
|
|
44
|
+
})
|
|
45
|
+
).toEqual(bypassAnswer)
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('returns the same bypass answer through createAccessModel', () => {
|
|
49
|
+
const accessModel = createAccessModel(accessTestOrganizationModel)
|
|
50
|
+
|
|
51
|
+
expect(
|
|
52
|
+
accessModel.checkAccess('unknown.system', {
|
|
53
|
+
...baseContext,
|
|
54
|
+
membership: null,
|
|
55
|
+
profile: { id: 'admin', isPlatformAdmin: true }
|
|
56
|
+
})
|
|
57
|
+
).toEqual(bypassAnswer)
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('does not grant platformAdmin access to non-platform admins', () => {
|
|
61
|
+
expect(checkAccess(AccessKeys.platformAdmin, baseContext)).toEqual({
|
|
62
|
+
allowed: false,
|
|
63
|
+
restrictedBy: 'role-permission',
|
|
64
|
+
reason: 'role-permission-denied'
|
|
65
|
+
})
|
|
66
|
+
})
|
|
67
|
+
})
|