@things-factory/auth-base 10.1.111 → 10.1.116

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@things-factory/auth-base",
3
- "version": "10.1.111",
3
+ "version": "10.1.116",
4
4
  "main": "dist-server/index.js",
5
5
  "browser": "dist-client/index.js",
6
6
  "things-factory": true,
@@ -48,5 +48,5 @@
48
48
  "passport-jwt": "^4.0.0",
49
49
  "passport-local": "^1.0.0"
50
50
  },
51
- "gitHead": "f33122c15e5b3e45393129e9418e2c367a273ade"
51
+ "gitHead": "49fa935cc703be70200eef4d29f473de98f871ce"
52
52
  }
@@ -10,6 +10,7 @@
10
10
  import { parse } from 'graphql'
11
11
 
12
12
  import { clearEntryTargets, declareEntryTarget } from '../../ai-assistant/server/service/data-entry/entry-target'
13
+ import { applyEntryProposal } from '../../ai-assistant/server/service/data-entry/entry-apply'
13
14
 
14
15
  import { closeAuthDatabase, Domain, getRepository, openAuthDatabase, resetAuthDatabase } from './db'
15
16
  import { loadCompiled } from './compiled'
@@ -22,6 +23,8 @@ const { Privilege } = loadCompiled('service/privilege/privilege')
22
23
  const { User } = loadCompiled('service/user/user')
23
24
  const access = loadCompiled('controllers/access-request')
24
25
  const targets = loadCompiled('ai-entry/access-entry-targets')
26
+ const { judgePermission } = loadCompiled('utils/check-permission')
27
+ const { AccessRequest } = loadCompiled('service/access-request/access-request')
25
28
 
26
29
  const rootFields = (document: string) =>
27
30
  ((parse(document).definitions[0] as any).selectionSet.selections as any[]).map(one => one.name.value)
@@ -31,6 +34,10 @@ const row = (values: Record<string, unknown>, index = 0) => ({ index, values, so
31
34
  describe('the declarations', () => {
32
35
  afterEach(() => clearEntryTargets())
33
36
 
37
+ it('the built public AUTH entry exports the same refusal formatter used by AI entry', () => {
38
+ expect(loadCompiled('index').privilegeRejection).toBe(loadCompiled('service/privilege/privilege-rejection').privilegeRejection)
39
+ })
40
+
34
41
  it('are accepted by the assistant as declared', () => {
35
42
  for (const target of targets.accessEntryTargets()) expect(() => declareEntryTarget(target)).not.toThrow()
36
43
  })
@@ -110,6 +117,35 @@ describe('the card says what the screen says', () => {
110
117
 
111
118
  const state = (user: any) => ({ domain, user })
112
119
 
120
+ it.each(['GRANT', 'DENY'])('a stored AI decision applies %s through the same controller and schedules the result after commit', async decision => {
121
+ const request = await access.requestAccess({ category: 'warehouse', privilege: 'mutation', reason: 'inbound' }, { state: { ...state(kim), afterCommit: [] } } as any)
122
+ const target = targets.accessDecisionEntryTarget()
123
+ const notify = jest.fn().mockResolvedValue(undefined)
124
+ const afterCommit: any[] = []
125
+ const context = { state: { ...state(park), notify, afterCommit }, t: (key: string) => key }
126
+ const execute = jest.fn(async (document, variables) => {
127
+ expect(document).toBe(target.mutation)
128
+ const decided = await access.decideAccessRequest(variables.decision, context)
129
+ return { data: { decideAccessRequest: decided } }
130
+ })
131
+ const proposal = { key: 'ai-decision', kind: 'entry', target: target.key, rows: [row({ request: { id: request.id }, decision, ...(decision === 'GRANT' ? { role: { id: inbound.id } } : { reason: 'IT only' }) })] }
132
+ const outcome = await applyEntryProposal(proposal.key, {
133
+ messages: [{ id: 'm', proposals: [proposal] }], answered: false, state: context.state,
134
+ passesDoor: async door => {
135
+ expect(door).toBe('Mutation.decideAccessRequest')
136
+ return (await judgePermission({ category: 'user', privilege: 'mutation', owner: true }, park, domain)).permitted
137
+ },
138
+ execute
139
+ }, key => key === target.key ? target : undefined)
140
+ expect(outcome).toMatchObject({ verdict: 'accepted', applied: 1, missed: 0 })
141
+ expect(execute).toHaveBeenCalledTimes(1)
142
+ expect(notify).not.toHaveBeenCalled()
143
+ for (const step of afterCommit) await step.run()
144
+ expect(notify).toHaveBeenCalledWith(expect.objectContaining({ receivers: [kim.id], url: 'my-access-requests', title: `access-request.notify.${decision === 'GRANT' ? 'granted' : 'denied'}.title` }))
145
+ expect((await getRepository(AccessRequest).findOneBy({ id: request.id })).state).toBe(decision === 'GRANT' ? 'GRANTED' : 'DENIED')
146
+ expect(await User.hasPrivilege('mutation', 'warehouse', domain, kim)).toBe(decision === 'GRANT')
147
+ })
148
+
113
149
  it('★ the card\'s findings are reviewDecision\'s, for grant and for deny', async () => {
114
150
  const request = await access.requestAccess({ category: 'warehouse', privilege: 'mutation', reason: 'inbound' }, { state: { ...state(kim), afterCommit: [] } } as any)
115
151
  const rows = [
@@ -0,0 +1,149 @@
1
+ /**
2
+ * A list reads this domain's rows only - whatever `inherited` the caller sent (2026-10-02).
3
+ *
4
+ * `getQueryBuilderFromListParams` honours `inherited: Include | Only`, so a list that hands the client's ListParam
5
+ * through lets a child domain read its parent's rows. For these lists a parent's rows are another tenant's
6
+ * configuration or people, and no screen asks for them: the query pins `inherited` to None. The same pin as
7
+ * `accessRequests` (access-request-db.test.ts).
8
+ *
9
+ * Roles are not here - the role screens ask for the parent's roles on purpose (assignable scope, docs/design/auth-rebuild.md).
10
+ */
11
+
12
+ import { closeAuthDatabase, Domain, getRepository, openAuthDatabase, resetAuthDatabase } from './db'
13
+ import { loadCompiled } from './compiled'
14
+
15
+ const { User } = loadCompiled('service/user/user')
16
+ const { AuthProvider } = loadCompiled('service/auth-provider/auth-provider')
17
+ const { AuthProviderQuery } = loadCompiled('service/auth-provider/auth-provider-query')
18
+ const { Appliance } = loadCompiled('service/appliance/appliance')
19
+ const { ApplianceQuery } = loadCompiled('service/appliance/appliance-query')
20
+ const { Application } = loadCompiled('service/application/application')
21
+ const { ApplicationQuery } = loadCompiled('service/application/application-query')
22
+ const { Invitation } = loadCompiled('service/invitation/invitation')
23
+ const { InvitationStatus } = loadCompiled('utils/invitation-state')
24
+ const { InvitationQuery } = loadCompiled('service/invitation/invitation-query')
25
+ const { Partner } = loadCompiled('service/partner/partner')
26
+ const { PartnerQuery } = loadCompiled('service/partner/partner-query')
27
+ const { LoginHistory } = loadCompiled('service/login-history/login-history')
28
+ const { LoginHistoryQuery } = loadCompiled('service/login-history/login-history-query')
29
+
30
+ let parent: any
31
+ let child: any
32
+ let partnerDomain: any
33
+ let parentUser: any
34
+ let childUser: any
35
+
36
+ async function makeUser(name: string, domains: any[]) {
37
+ return await getRepository(User).save({
38
+ username: name,
39
+ email: `${name}@example.com`,
40
+ name,
41
+ salt: User.generateSalt(),
42
+ status: 'activated',
43
+ domains
44
+ })
45
+ }
46
+
47
+ const from = (domain: any, user: any) => ({ state: { domain, user } }) as any
48
+ const ONLY = { inherited: 'Only' } as any
49
+
50
+ beforeAll(async () => {
51
+ await openAuthDatabase()
52
+ })
53
+
54
+ afterAll(async () => {
55
+ await closeAuthDatabase()
56
+ })
57
+
58
+ beforeEach(async () => {
59
+ await resetAuthDatabase()
60
+
61
+ const savedParent = await getRepository(Domain).save({ name: 'Plant A', subdomain: 'plant-a' })
62
+ parent = await getRepository(Domain).findOneBy({ id: savedParent.id })
63
+ const savedChild = await getRepository(Domain).save({ name: 'Plant A-1', subdomain: 'plant-a-1', parent })
64
+ child = await getRepository(Domain).findOneBy({ id: savedChild.id })
65
+ partnerDomain = await getRepository(Domain).save({ name: 'Vendor', subdomain: 'vendor' })
66
+
67
+ parentUser = await makeUser('parent-admin', [parent])
68
+ childUser = await makeUser('child-admin', [child])
69
+ })
70
+
71
+ /*
72
+ * Each list: the parent reads its own row (so an empty answer below is the pin, not a broken fixture), and the
73
+ * child asking for the parent's with `inherited: Only` reads nothing.
74
+ */
75
+ const LISTS: [string, () => Promise<any>, (params: any, context: any) => Promise<any>][] = [
76
+ [
77
+ 'auth providers',
78
+ () => getRepository(AuthProvider).save({ domain: parent, type: 'keycloak', clientSecret: 'parent-secret' }),
79
+ (params, context) => new AuthProviderQuery().authProviders(params, context)
80
+ ],
81
+ [
82
+ 'appliances',
83
+ () => getRepository(Appliance).save({ domain: parent, name: 'gate', serialNo: 'gate-SN', brand: 'Acme', model: 'X1' }),
84
+ (params, context) => new ApplianceQuery().appliances(params, context)
85
+ ],
86
+ [
87
+ 'edges',
88
+ () => getRepository(Appliance).save({ domain: parent, name: 'edge', serialNo: 'edge-SN', brand: 'Acme', model: 'X1' }),
89
+ (params, context) => new ApplianceQuery().edges(params, context)
90
+ ],
91
+ [
92
+ 'applications',
93
+ () =>
94
+ getRepository(Application).save({
95
+ domain: parent,
96
+ name: 'billing',
97
+ email: 'billing@acme.z',
98
+ url: 'https://billing.example.com',
99
+ redirectUrl: 'https://billing.example.com/callback',
100
+ status: 0,
101
+ type: 'OTHERS',
102
+ appSecret: 'parent-app-secret'
103
+ }),
104
+ (params, context) => new ApplicationQuery().applications(params, context)
105
+ ],
106
+ [
107
+ 'invitations',
108
+ () =>
109
+ getRepository(Invitation).save({
110
+ email: 'lee@acme.z',
111
+ domain: parent,
112
+ status: InvitationStatus.PENDING,
113
+ token: 'parent-token',
114
+ expiresAt: new Date(Date.now() + 3600_000)
115
+ }),
116
+ (params, context) => new InvitationQuery().invitations(params, context)
117
+ ],
118
+ [
119
+ 'partners',
120
+ () => getRepository(Partner).save({ domain: parent, partnerDomain, requestedAt: new Date() }),
121
+ (params, context) => new PartnerQuery().partners(params, context)
122
+ ],
123
+ [
124
+ 'login histories',
125
+ () => LoginHistory.stamp(parent, parentUser, '10.0.0.1'),
126
+ (params, context) => new LoginHistoryQuery().loginHistories(params, context)
127
+ ]
128
+ ]
129
+
130
+ describe.each(LISTS)('%s', (_name, seed, list) => {
131
+ it("★ the parent reads its own row - the fixture is real", async () => {
132
+ await seed()
133
+ const answer = await list({}, from(parent, parentUser))
134
+ expect(answer.items).toHaveLength(1)
135
+ })
136
+
137
+ it("★ a child domain asking for the parent's with inherited: Only reads nothing", async () => {
138
+ await seed()
139
+ const answer = await list(ONLY, from(child, childUser))
140
+ expect(answer.items).toEqual([])
141
+ expect(answer.total).toBe(0)
142
+ })
143
+
144
+ it("a child domain asking with inherited: Include reads nothing of the parent's", async () => {
145
+ await seed()
146
+ const answer = await list({ inherited: 'Include' }, from(child, childUser))
147
+ expect(answer.items).toEqual([])
148
+ })
149
+ })