@things-factory/auth-base 10.1.31 → 10.1.33
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-server/controllers/invitation.d.ts +47 -10
- package/dist-server/controllers/invitation.js +159 -113
- package/dist-server/controllers/invitation.js.map +1 -1
- package/dist-server/controllers/profile.d.ts +1 -0
- package/dist-server/router/auth-public-process-router.js +36 -13
- package/dist-server/router/auth-public-process-router.js.map +1 -1
- package/dist-server/router/oauth2/oauth2-router.js +7 -2
- package/dist-server/router/oauth2/oauth2-router.js.map +1 -1
- package/dist-server/router/oauth2/oauth2-server.js +4 -4
- package/dist-server/router/oauth2/oauth2-server.js.map +1 -1
- package/dist-server/service/app-binding/app-binding-mutation.d.ts +27 -0
- package/dist-server/service/app-binding/app-binding-mutation.js +39 -6
- package/dist-server/service/app-binding/app-binding-mutation.js.map +1 -1
- package/dist-server/service/app-binding/app-binding-query.js +14 -7
- package/dist-server/service/app-binding/app-binding-query.js.map +1 -1
- package/dist-server/service/app-binding/app-binding.d.ts +9 -0
- package/dist-server/service/app-binding/app-binding.js +10 -1
- package/dist-server/service/app-binding/app-binding.js.map +1 -1
- package/dist-server/service/appliance/appliance-mutation.js +34 -1
- package/dist-server/service/appliance/appliance-mutation.js.map +1 -1
- package/dist-server/service/appliance/appliance-query.d.ts +2 -0
- package/dist-server/service/appliance/appliance-query.js +48 -0
- package/dist-server/service/appliance/appliance-query.js.map +1 -1
- package/dist-server/service/appliance/appliance.d.ts +1 -0
- package/dist-server/service/appliance/appliance.js +33 -3
- package/dist-server/service/appliance/appliance.js.map +1 -1
- package/dist-server/service/application/application-mutation.js +51 -6
- package/dist-server/service/application/application-mutation.js.map +1 -1
- package/dist-server/service/application/application.d.ts +9 -3
- package/dist-server/service/application/application.js +24 -8
- package/dist-server/service/application/application.js.map +1 -1
- package/dist-server/service/auth-provider/auth-provider-mutation.js +5 -0
- package/dist-server/service/auth-provider/auth-provider-mutation.js.map +1 -1
- package/dist-server/service/domain-generator/domain-generator-mutation.js +3 -0
- package/dist-server/service/domain-generator/domain-generator-mutation.js.map +1 -1
- package/dist-server/service/invitation/invitation-mutation.d.ts +17 -15
- package/dist-server/service/invitation/invitation-mutation.js +83 -56
- package/dist-server/service/invitation/invitation-mutation.js.map +1 -1
- package/dist-server/service/invitation/invitation-query.d.ts +20 -5
- package/dist-server/service/invitation/invitation-query.js +60 -19
- package/dist-server/service/invitation/invitation-query.js.map +1 -1
- package/dist-server/service/invitation/invitation.d.ts +14 -2
- package/dist-server/service/invitation/invitation.js +77 -8
- package/dist-server/service/invitation/invitation.js.map +1 -1
- package/dist-server/service/login-history/login-history-query.js +3 -0
- package/dist-server/service/login-history/login-history-query.js.map +1 -1
- package/dist-server/service/role/role-mutation.js +14 -15
- package/dist-server/service/role/role-mutation.js.map +1 -1
- package/dist-server/service/role/role-query.js +26 -4
- package/dist-server/service/role/role-query.js.map +1 -1
- package/dist-server/service/role/role-types.d.ts +2 -0
- package/dist-server/service/role/role-types.js +8 -0
- package/dist-server/service/role/role-types.js.map +1 -1
- package/dist-server/service/role-template/role-template-mutation.d.ts +17 -1
- package/dist-server/service/role-template/role-template-mutation.js +14 -3
- package/dist-server/service/role-template/role-template-mutation.js.map +1 -1
- package/dist-server/service/user/user-mutation.js +1 -0
- package/dist-server/service/user/user-mutation.js.map +1 -1
- package/dist-server/service/user/user.d.ts +1 -0
- package/dist-server/service/user/user.js +19 -4
- package/dist-server/service/user/user.js.map +1 -1
- package/dist-server/templates/invitation-email.d.ts +2 -1
- package/dist-server/templates/invitation-email.js +16 -4
- package/dist-server/templates/invitation-email.js.map +1 -1
- package/dist-server/tsconfig.tsbuildinfo +1 -1
- package/dist-server/utils/credential-at-rest.d.ts +28 -0
- package/dist-server/utils/credential-at-rest.js +36 -0
- package/dist-server/utils/credential-at-rest.js.map +1 -0
- package/dist-server/utils/credential-features.d.ts +35 -0
- package/dist-server/utils/credential-features.js +45 -0
- package/dist-server/utils/credential-features.js.map +1 -0
- package/dist-server/utils/credential-serial-rule.d.ts +41 -0
- package/dist-server/utils/credential-serial-rule.js +43 -0
- package/dist-server/utils/credential-serial-rule.js.map +1 -0
- package/dist-server/utils/invitation-state.d.ts +52 -0
- package/dist-server/utils/invitation-state.js +76 -0
- package/dist-server/utils/invitation-state.js.map +1 -0
- package/dist-server/utils/refuse-role-name.d.ts +42 -0
- package/dist-server/utils/refuse-role-name.js +76 -0
- package/dist-server/utils/refuse-role-name.js.map +1 -0
- package/dist-server/utils/role-name-standing.d.ts +58 -0
- package/dist-server/utils/role-name-standing.js +72 -0
- package/dist-server/utils/role-name-standing.js.map +1 -0
- package/package.json +4 -4
- package/tests/app-binding-delete-db.test.ts +188 -0
- package/tests/appliance-credential-state-db.test.ts +207 -0
- package/tests/appliance-delete-db.test.ts +119 -0
- package/tests/appliance-revoke-db.test.ts +73 -28
- package/tests/appliance-schema.test.ts +129 -0
- package/tests/application-delete-db.test.ts +190 -0
- package/tests/application-token-payload-db.test.ts +185 -0
- package/tests/checkin-privilege-seam-db.test.ts +158 -0
- package/tests/credential-at-rest-db.test.ts +246 -0
- package/tests/credential-features-off-db.test.ts +189 -0
- package/tests/credential-serial-rule.test.ts +84 -0
- package/tests/invitation-db.test.ts +416 -0
- package/tests/invitation-state.test.ts +92 -0
- package/tests/open-doors.test.ts +132 -0
- package/tests/role-inheritance-db.test.ts +286 -0
- package/tests/role-mutation-db.test.ts +21 -7
- package/tests/role-name-lookup-sentinel.test.ts +79 -0
- package/tests/role-name-standing.test.ts +83 -0
- package/tests/token-issuance.test.ts +7 -3
- package/translations/en.json +2 -0
- package/translations/ja.json +2 -0
- package/translations/ko.json +2 -0
- package/translations/ms.json +2 -0
- package/translations/zh.json +2 -0
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Whether a role may be given a name that is already standing. (Pure rule — ADR-0062 decision 3.)
|
|
4
|
+
*
|
|
5
|
+
* ## The situation
|
|
6
|
+
*
|
|
7
|
+
* A domain sees its own roles and its parent's. Until this rule, the duplicate checks asked only
|
|
8
|
+
* about the domain itself, so a domain with a parent could be given a role whose name was already
|
|
9
|
+
* visible through inheritance. Two rows then answered to one name: every list showed both, and
|
|
10
|
+
* resolving the name picked one. An approval line could end up pointing at the one nobody looks
|
|
11
|
+
* at.
|
|
12
|
+
*
|
|
13
|
+
* ## What is refused, and what is not
|
|
14
|
+
*
|
|
15
|
+
* ```
|
|
16
|
+
* here the domain already has this name always refused
|
|
17
|
+
* inherited the parent has it and this domain not refused unless the operator says so
|
|
18
|
+
* nowhere neither stands
|
|
19
|
+
* ```
|
|
20
|
+
*
|
|
21
|
+
* ## Why the escape hatch does not ask for a different name
|
|
22
|
+
*
|
|
23
|
+
* ⚠ This is the part that is easy to get backwards. ADR-0062 decision 2 sets out the only path
|
|
24
|
+
* for moving a domain off an inherited structure: **stand the role up here → move the people to
|
|
25
|
+
* the role of the same name → cut the parent last.** The name being the same is the point of it
|
|
26
|
+
* — the domain's own role is standing up to *replace* what it inherits, and once the parent is
|
|
27
|
+
* cut there is one name again.
|
|
28
|
+
*
|
|
29
|
+
* So requiring a different name would close the one route decision 2 defines. The escape is the
|
|
30
|
+
* operator saying, in as many words, that this domain stands its own — and the name stays.
|
|
31
|
+
* Asking for a different name remains available on top of that, for when somebody genuinely
|
|
32
|
+
* wants a second seat with the same privileges; it is a choice, never a requirement.
|
|
33
|
+
*/
|
|
34
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
35
|
+
exports.nameStands = nameStands;
|
|
36
|
+
exports.refusesName = refusesName;
|
|
37
|
+
/**
|
|
38
|
+
* Where the name stands.
|
|
39
|
+
*
|
|
40
|
+
* A role found in neither this domain nor its parent is `nowhere` — the caller is expected to
|
|
41
|
+
* have looked in those two places, and a row from somewhere else is not this domain's business.
|
|
42
|
+
*/
|
|
43
|
+
function nameStands(found, domain) {
|
|
44
|
+
const where = found?.domainId;
|
|
45
|
+
if (!where) {
|
|
46
|
+
return 'nowhere';
|
|
47
|
+
}
|
|
48
|
+
if (where === domain?.id) {
|
|
49
|
+
return 'here';
|
|
50
|
+
}
|
|
51
|
+
if (domain?.parentId && where === domain.parentId) {
|
|
52
|
+
return 'inherited';
|
|
53
|
+
}
|
|
54
|
+
return 'nowhere';
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Does the name refuse?
|
|
58
|
+
*
|
|
59
|
+
* `separately` is the operator saying this domain stands its own of that name. It reaches only
|
|
60
|
+
* the inherited case — a name already taken **in this domain** is not a thing anybody can opt
|
|
61
|
+
* into, because the two rows would be indistinguishable in every respect.
|
|
62
|
+
*/
|
|
63
|
+
function refusesName(standing, separately) {
|
|
64
|
+
if (standing === 'here') {
|
|
65
|
+
return true;
|
|
66
|
+
}
|
|
67
|
+
if (standing === 'inherited') {
|
|
68
|
+
return !separately;
|
|
69
|
+
}
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
//# sourceMappingURL=role-name-standing.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"role-name-standing.js","sourceRoot":"","sources":["../../server/utils/role-name-standing.ts"],"names":[],"mappings":";AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+BG;;AAsBH,gCAgBC;AASD,kCAUC;AAzCD;;;;;GAKG;AACH,SAAgB,UAAU,CAAC,KAAmB,EAAE,MAAoB;IAClE,MAAM,KAAK,GAAG,KAAK,EAAE,QAAQ,CAAA;IAE7B,IAAI,CAAC,KAAK,EAAE,CAAC;QACX,OAAO,SAAS,CAAA;IAClB,CAAC;IAED,IAAI,KAAK,KAAK,MAAM,EAAE,EAAE,EAAE,CAAC;QACzB,OAAO,MAAM,CAAA;IACf,CAAC;IAED,IAAI,MAAM,EAAE,QAAQ,IAAI,KAAK,KAAK,MAAM,CAAC,QAAQ,EAAE,CAAC;QAClD,OAAO,WAAW,CAAA;IACpB,CAAC;IAED,OAAO,SAAS,CAAA;AAClB,CAAC;AAED;;;;;;GAMG;AACH,SAAgB,WAAW,CAAC,QAAsB,EAAE,UAAoB;IACtE,IAAI,QAAQ,KAAK,MAAM,EAAE,CAAC;QACxB,OAAO,IAAI,CAAA;IACb,CAAC;IAED,IAAI,QAAQ,KAAK,WAAW,EAAE,CAAC;QAC7B,OAAO,CAAC,UAAU,CAAA;IACpB,CAAC;IAED,OAAO,KAAK,CAAA;AACd,CAAC","sourcesContent":["/**\n * Whether a role may be given a name that is already standing. (Pure rule — ADR-0062 decision 3.)\n *\n * ## The situation\n *\n * A domain sees its own roles and its parent's. Until this rule, the duplicate checks asked only\n * about the domain itself, so a domain with a parent could be given a role whose name was already\n * visible through inheritance. Two rows then answered to one name: every list showed both, and\n * resolving the name picked one. An approval line could end up pointing at the one nobody looks\n * at.\n *\n * ## What is refused, and what is not\n *\n * ```\n * here the domain already has this name always refused\n * inherited the parent has it and this domain not refused unless the operator says so\n * nowhere neither stands\n * ```\n *\n * ## Why the escape hatch does not ask for a different name\n *\n * ⚠ This is the part that is easy to get backwards. ADR-0062 decision 2 sets out the only path\n * for moving a domain off an inherited structure: **stand the role up here → move the people to\n * the role of the same name → cut the parent last.** The name being the same is the point of it\n * — the domain's own role is standing up to *replace* what it inherits, and once the parent is\n * cut there is one name again.\n *\n * So requiring a different name would close the one route decision 2 defines. The escape is the\n * operator saying, in as many words, that this domain stands its own — and the name stays.\n * Asking for a different name remains available on top of that, for when somebody genuinely\n * wants a second seat with the same privileges; it is a choice, never a requirement.\n */\n\n/** Where a role of this name already stands, seen from the domain that is asking. */\nexport type NameStanding = 'nowhere' | 'here' | 'inherited'\n\n/** The domain asking. `parentId` absent means it inherits from nobody. */\nexport type AskingDomain = {\n id: string\n parentId?: string | null\n}\n\n/** A role that was found carrying the name, or nothing. */\nexport type StandingRole = {\n domainId?: string | null\n} | null | undefined\n\n/**\n * Where the name stands.\n *\n * A role found in neither this domain nor its parent is `nowhere` — the caller is expected to\n * have looked in those two places, and a row from somewhere else is not this domain's business.\n */\nexport function nameStands(found: StandingRole, domain: AskingDomain): NameStanding {\n const where = found?.domainId\n\n if (!where) {\n return 'nowhere'\n }\n\n if (where === domain?.id) {\n return 'here'\n }\n\n if (domain?.parentId && where === domain.parentId) {\n return 'inherited'\n }\n\n return 'nowhere'\n}\n\n/**\n * Does the name refuse?\n *\n * `separately` is the operator saying this domain stands its own of that name. It reaches only\n * the inherited case — a name already taken **in this domain** is not a thing anybody can opt\n * into, because the two rows would be indistinguishable in every respect.\n */\nexport function refusesName(standing: NameStanding, separately?: boolean): boolean {\n if (standing === 'here') {\n return true\n }\n\n if (standing === 'inherited') {\n return !separately\n }\n\n return false\n}\n"]}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@things-factory/auth-base",
|
|
3
|
-
"version": "10.1.
|
|
3
|
+
"version": "10.1.33",
|
|
4
4
|
"main": "dist-server/index.js",
|
|
5
5
|
"browser": "dist-client/index.js",
|
|
6
6
|
"things-factory": true,
|
|
@@ -34,9 +34,9 @@
|
|
|
34
34
|
"@reduxjs/toolkit": "^2.2.5",
|
|
35
35
|
"@simplewebauthn/browser": "^13.0.0",
|
|
36
36
|
"@simplewebauthn/server": "^13.0.0",
|
|
37
|
-
"@things-factory/email-base": "^10.1.
|
|
37
|
+
"@things-factory/email-base": "^10.1.33",
|
|
38
38
|
"@things-factory/env": "^10.1.20",
|
|
39
|
-
"@things-factory/shell": "^10.1.
|
|
39
|
+
"@things-factory/shell": "^10.1.33",
|
|
40
40
|
"@things-factory/utils": "^10.1.20",
|
|
41
41
|
"@types/webappsec-credential-management": "^0.6.9",
|
|
42
42
|
"jsonwebtoken": "^9.0.0",
|
|
@@ -48,5 +48,5 @@
|
|
|
48
48
|
"passport-jwt": "^4.0.0",
|
|
49
49
|
"passport-local": "^1.0.0"
|
|
50
50
|
},
|
|
51
|
-
"gitHead": "
|
|
51
|
+
"gitHead": "7fb7b6587846b5ff6ee7a4983652d9561b4d6b2c"
|
|
52
52
|
}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `deleteAppBinding` — cutting one client off without touching the application.
|
|
3
|
+
*
|
|
4
|
+
* ── What the door was ───────────────────────────────────────────────────────
|
|
5
|
+
* It read the domain out of the context, used it for nothing, and ran
|
|
6
|
+
*
|
|
7
|
+
* getRepository(User).delete({ id })
|
|
8
|
+
*
|
|
9
|
+
* That criteria names neither the kind of row nor the tenant. `AppBinding` is a `User` wearing
|
|
10
|
+
* a type, so every account in the installation is the same kind of row as the one this was for:
|
|
11
|
+
* a person, a domain owner, an appliance's shadow user, another tenant's binding. Any id at all
|
|
12
|
+
* deleted that row and answered true.
|
|
13
|
+
*
|
|
14
|
+
* It carried no `@privilege` either, so the schema recorded it as open and anyone holding a
|
|
15
|
+
* valid token could reach it.
|
|
16
|
+
*
|
|
17
|
+
* ── Why revocation is a delete here ─────────────────────────────────────────
|
|
18
|
+
* An appliance keeps its identity when its credential is taken away, because past rows point at
|
|
19
|
+
* it as creator and updater and a new secret should land on the same subject. A binding is not
|
|
20
|
+
* like that: it is created by the OAuth2 exchange whenever a client is granted access, and the
|
|
21
|
+
* grant is the only thing it records. Deleting it is the revocation — there is no second state
|
|
22
|
+
* for it to be in (ADR-0055).
|
|
23
|
+
*
|
|
24
|
+
* ⚠ sqlite (`tests/db.ts`).
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { closeAuthDatabase, Domain, getRepository, openAuthDatabase, resetAuthDatabase } from './db'
|
|
28
|
+
import { loadCompiled } from './compiled'
|
|
29
|
+
|
|
30
|
+
const { Application } = loadCompiled('service/application/application')
|
|
31
|
+
const { User, UserStatus } = loadCompiled('service/user/user')
|
|
32
|
+
const { AppBindingMutation } = loadCompiled('service/app-binding/app-binding-mutation')
|
|
33
|
+
const { ApplicationMutation } = loadCompiled('service/application/application-mutation')
|
|
34
|
+
|
|
35
|
+
const mutation = new AppBindingMutation()
|
|
36
|
+
const applications = new ApplicationMutation()
|
|
37
|
+
|
|
38
|
+
let ours: any
|
|
39
|
+
let theirs: any
|
|
40
|
+
let actor: any
|
|
41
|
+
|
|
42
|
+
function context(d: any) {
|
|
43
|
+
return {
|
|
44
|
+
state: { domain: d, user: actor, tx: undefined },
|
|
45
|
+
throw: (code: number, message: string) => {
|
|
46
|
+
const error: any = new Error(message)
|
|
47
|
+
error.status = code
|
|
48
|
+
throw error
|
|
49
|
+
}
|
|
50
|
+
} as any
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function makeApplication(name: string, d: any) {
|
|
54
|
+
return await applications.createApplication(
|
|
55
|
+
{
|
|
56
|
+
name,
|
|
57
|
+
email: `${name}@acme.z`,
|
|
58
|
+
url: `https://${name}.example.com`,
|
|
59
|
+
redirectUrl: `https://${name}.example.com/callback`,
|
|
60
|
+
type: 'OTHERS'
|
|
61
|
+
},
|
|
62
|
+
context(d)
|
|
63
|
+
)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function bind(application: any, label: string, d: any) {
|
|
67
|
+
return await getRepository(User).save({
|
|
68
|
+
username: `${label}@acme`,
|
|
69
|
+
email: `${label}@acme`,
|
|
70
|
+
name: label,
|
|
71
|
+
userType: 'application',
|
|
72
|
+
reference: application.id,
|
|
73
|
+
status: UserStatus.ACTIVATED,
|
|
74
|
+
domains: [d]
|
|
75
|
+
})
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function stillThere(id: string) {
|
|
79
|
+
return !!(await getRepository(User).findOneBy({ id }))
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
beforeAll(async () => {
|
|
83
|
+
await openAuthDatabase()
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
afterAll(async () => {
|
|
87
|
+
await closeAuthDatabase()
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
beforeEach(async () => {
|
|
91
|
+
await resetAuthDatabase()
|
|
92
|
+
|
|
93
|
+
ours = await getRepository(Domain).save({ name: 'acme', subdomain: 'acme' })
|
|
94
|
+
theirs = await getRepository(Domain).save({ name: 'beta', subdomain: 'beta' })
|
|
95
|
+
actor = await getRepository(User).save({
|
|
96
|
+
username: 'operator@acme.z',
|
|
97
|
+
email: 'operator@acme.z',
|
|
98
|
+
name: 'operator',
|
|
99
|
+
status: UserStatus.ACTIVATED,
|
|
100
|
+
domains: [ours]
|
|
101
|
+
})
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
describe('cutting off a binding of ours', () => {
|
|
105
|
+
it('deletes it', async () => {
|
|
106
|
+
const application = await makeApplication('billing', ours)
|
|
107
|
+
const binding = await bind(application, 'binding-a', ours)
|
|
108
|
+
|
|
109
|
+
await mutation.deleteAppBinding(binding.id, context(ours))
|
|
110
|
+
|
|
111
|
+
expect(await stillThere(binding.id)).toBe(false)
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it('leaves the application and its other bindings alone', async () => {
|
|
115
|
+
/* Revoking one client is not deleting the application. */
|
|
116
|
+
const application = await makeApplication('billing', ours)
|
|
117
|
+
const going = await bind(application, 'binding-a', ours)
|
|
118
|
+
const staying = await bind(application, 'binding-b', ours)
|
|
119
|
+
|
|
120
|
+
await mutation.deleteAppBinding(going.id, context(ours))
|
|
121
|
+
|
|
122
|
+
expect(await stillThere(staying.id)).toBe(true)
|
|
123
|
+
expect(await getRepository(Application).findOneBy({ id: application.id })).not.toBeNull()
|
|
124
|
+
})
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
describe('an id that is not a binding of ours', () => {
|
|
128
|
+
it('does not delete a person', async () => {
|
|
129
|
+
/*
|
|
130
|
+
* ⚠ The worst of these. A person's account is the same kind of row as a binding, so
|
|
131
|
+
* `delete({ id })` took it with the same answer — and the caller did not have to be anyone
|
|
132
|
+
* in particular, because the mutation carried no privilege at all.
|
|
133
|
+
*/
|
|
134
|
+
const person = await getRepository(User).save({
|
|
135
|
+
username: 'kim@acme.z',
|
|
136
|
+
email: 'kim@acme.z',
|
|
137
|
+
name: 'kim',
|
|
138
|
+
status: UserStatus.ACTIVATED,
|
|
139
|
+
domains: [ours]
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
await mutation.deleteAppBinding(person.id, context(ours))
|
|
143
|
+
|
|
144
|
+
expect(await stillThere(person.id)).toBe(true)
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
it('does not delete an appliance shadow user', async () => {
|
|
148
|
+
/*
|
|
149
|
+
* ⚠ Deleting one stops the device at its next request and leaves the appliance on screen
|
|
150
|
+
* looking registered — the same end state as the appliance delete leak, reached from here.
|
|
151
|
+
*/
|
|
152
|
+
const applianceUser = await getRepository(User).save({
|
|
153
|
+
username: 'device@acme',
|
|
154
|
+
email: 'device@acme',
|
|
155
|
+
name: 'device',
|
|
156
|
+
userType: 'appliance',
|
|
157
|
+
reference: 'some-appliance-id',
|
|
158
|
+
status: UserStatus.ACTIVATED,
|
|
159
|
+
domains: [ours]
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
await mutation.deleteAppBinding(applianceUser.id, context(ours))
|
|
163
|
+
|
|
164
|
+
expect(await stillThere(applianceUser.id)).toBe(true)
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
it('does not delete another tenant s binding', async () => {
|
|
168
|
+
/* ⚠ The tenant boundary. `appBinding` already refuses to read one; this deleted it. */
|
|
169
|
+
const application = await makeApplication('billing', theirs)
|
|
170
|
+
const foreign = await bind(application, 'binding-theirs', theirs)
|
|
171
|
+
|
|
172
|
+
await mutation.deleteAppBinding(foreign.id, context(ours))
|
|
173
|
+
|
|
174
|
+
expect(await stillThere(foreign.id)).toBe(true)
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
it('answers the same whether the id was ours, theirs, or nothing', async () => {
|
|
178
|
+
/*
|
|
179
|
+
* Telling them apart would confirm that some id is real somewhere else — the reason
|
|
180
|
+
* `appBinding` answers nothing for both cases.
|
|
181
|
+
*/
|
|
182
|
+
const application = await makeApplication('billing', theirs)
|
|
183
|
+
const foreign = await bind(application, 'binding-theirs', theirs)
|
|
184
|
+
|
|
185
|
+
await expect(mutation.deleteAppBinding(foreign.id, context(ours))).resolves.toBe(true)
|
|
186
|
+
await expect(mutation.deleteAppBinding('no-such-id', context(ours))).resolves.toBe(true)
|
|
187
|
+
})
|
|
188
|
+
})
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the screen is allowed to ask about a credential (ADR-0085, the revocation screen).
|
|
3
|
+
*
|
|
4
|
+
* Two questions, and they are not the same one:
|
|
5
|
+
*
|
|
6
|
+
* applianceCredentialRevocable does revoking work here at all?
|
|
7
|
+
* Appliance.credentialRevoked has this one been revoked?
|
|
8
|
+
*
|
|
9
|
+
* ── Why the first exists ────────────────────────────────────────────────────
|
|
10
|
+
* Revocation is behind `credential/revocable`, and the client cannot read configuration. Without
|
|
11
|
+
* an answer the screen either hides a feature the installation asked for or shows a button that
|
|
12
|
+
* always refuses — and a button that always refuses reads as a broken product rather than as
|
|
13
|
+
* something nobody turned on.
|
|
14
|
+
*
|
|
15
|
+
* ── Why the second exists ───────────────────────────────────────────────────
|
|
16
|
+
* Revoking clears `accessToken`, so a revoked appliance and one that was never issued a
|
|
17
|
+
* credential look identical on screen: an empty box. They are opposite situations. The state
|
|
18
|
+
* that tells them apart is on the shadow user, not on the appliance row, so the field has to be
|
|
19
|
+
* resolved rather than read.
|
|
20
|
+
*
|
|
21
|
+
* ⚠ sqlite (`tests/db.ts`).
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { config } from '@things-factory/env'
|
|
25
|
+
|
|
26
|
+
import { closeAuthDatabase, Domain, getRepository, openAuthDatabase, resetAuthDatabase } from './db'
|
|
27
|
+
import { loadCompiled } from './compiled'
|
|
28
|
+
|
|
29
|
+
const { Appliance } = loadCompiled('service/appliance/appliance')
|
|
30
|
+
const { User, UserStatus } = loadCompiled('service/user/user')
|
|
31
|
+
const { ApplianceMutation } = loadCompiled('service/appliance/appliance-mutation')
|
|
32
|
+
const { ApplianceQuery } = loadCompiled('service/appliance/appliance-query')
|
|
33
|
+
|
|
34
|
+
const mutation = new ApplianceMutation()
|
|
35
|
+
const query = new ApplianceQuery()
|
|
36
|
+
|
|
37
|
+
const configGet = config.get
|
|
38
|
+
|
|
39
|
+
function revocationOff() {
|
|
40
|
+
config.get = configGet
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function revocationOn() {
|
|
44
|
+
config.get = ((key: string, fallback?: any) =>
|
|
45
|
+
key === 'credential/revocable' ? true : configGet(key, fallback)) as typeof config.get
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
let domain: any
|
|
49
|
+
let actor: any
|
|
50
|
+
|
|
51
|
+
function context(d: any = domain) {
|
|
52
|
+
return {
|
|
53
|
+
state: { domain: d, user: actor, tx: undefined },
|
|
54
|
+
throw: (code: number, message: string) => {
|
|
55
|
+
const error: any = new Error(message)
|
|
56
|
+
error.status = code
|
|
57
|
+
throw error
|
|
58
|
+
}
|
|
59
|
+
} as any
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function register(name: string) {
|
|
63
|
+
return await mutation.createAppliance({ name, serialNo: `${name}-SN`, brand: 'Acme', model: 'X1' }, context())
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
beforeAll(async () => {
|
|
67
|
+
await openAuthDatabase()
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
afterAll(async () => {
|
|
71
|
+
revocationOff()
|
|
72
|
+
await closeAuthDatabase()
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
beforeEach(async () => {
|
|
76
|
+
revocationOff()
|
|
77
|
+
await resetAuthDatabase()
|
|
78
|
+
|
|
79
|
+
domain = await getRepository(Domain).save({ name: 'acme', subdomain: 'acme' })
|
|
80
|
+
actor = await getRepository(User).save({
|
|
81
|
+
username: 'operator@acme.z',
|
|
82
|
+
email: 'operator@acme.z',
|
|
83
|
+
name: 'operator',
|
|
84
|
+
status: UserStatus.ACTIVATED,
|
|
85
|
+
domains: [domain]
|
|
86
|
+
})
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
describe('does revoking work here', () => {
|
|
90
|
+
it('says no where the installation configured nothing', async () => {
|
|
91
|
+
/*
|
|
92
|
+
* ⚠ The default. An installation that takes this release and changes no configuration must
|
|
93
|
+
* not grow a button, because the mutation behind it answers 404.
|
|
94
|
+
*/
|
|
95
|
+
expect(await query.applianceCredentialRevocable()).toBe(false)
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it('says yes once the installation turns it on', async () => {
|
|
99
|
+
revocationOn()
|
|
100
|
+
|
|
101
|
+
expect(await query.applianceCredentialRevocable()).toBe(true)
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it('answers the same thing the door does', async () => {
|
|
105
|
+
/*
|
|
106
|
+
* ⚠ The two have to move together. If the answer said yes while the mutation refused, the
|
|
107
|
+
* screen would offer an action that cannot be completed — which is the exact thing this
|
|
108
|
+
* query exists to prevent.
|
|
109
|
+
*/
|
|
110
|
+
const appliance = await register('gate-a')
|
|
111
|
+
|
|
112
|
+
expect(await query.applianceCredentialRevocable()).toBe(false)
|
|
113
|
+
await expect(mutation.revokeApplianceCredential(appliance.id, context())).rejects.toThrow(/not enabled/)
|
|
114
|
+
|
|
115
|
+
revocationOn()
|
|
116
|
+
|
|
117
|
+
expect(await query.applianceCredentialRevocable()).toBe(true)
|
|
118
|
+
await expect(mutation.revokeApplianceCredential(appliance.id, context())).resolves.toBeTruthy()
|
|
119
|
+
})
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
describe('has this one been revoked', () => {
|
|
123
|
+
it('says no for an appliance nobody has issued a credential for', async () => {
|
|
124
|
+
/*
|
|
125
|
+
* ⚠ The case the whole field is for. This appliance and a revoked one both show an empty
|
|
126
|
+
* access token; only this answer separates "waiting to be set up" from "deliberately shut
|
|
127
|
+
* off".
|
|
128
|
+
*/
|
|
129
|
+
const appliance = await register('gate-b')
|
|
130
|
+
|
|
131
|
+
expect(await query.credentialRevoked(appliance)).toBe(false)
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
it('says no for one holding a working credential', async () => {
|
|
135
|
+
const issued = await mutation.generateApplianceSecret((await register('gate-c')).id, context())
|
|
136
|
+
|
|
137
|
+
expect(await query.credentialRevoked(issued)).toBe(false)
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
it('says yes once it has been revoked', async () => {
|
|
141
|
+
revocationOn()
|
|
142
|
+
|
|
143
|
+
const issued = await mutation.generateApplianceSecret((await register('gate-d')).id, context())
|
|
144
|
+
const revoked = await mutation.revokeApplianceCredential(issued.id, context())
|
|
145
|
+
|
|
146
|
+
expect(await query.credentialRevoked(revoked)).toBe(true)
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
it('says no again after a new credential is issued', async () => {
|
|
150
|
+
/*
|
|
151
|
+
* Re-issuing puts the shadow user back to ACTIVATED, which is what makes the new token
|
|
152
|
+
* usable. The screen has to follow, or a working appliance keeps saying it was revoked.
|
|
153
|
+
*/
|
|
154
|
+
revocationOn()
|
|
155
|
+
|
|
156
|
+
const issued = await mutation.generateApplianceSecret((await register('gate-e')).id, context())
|
|
157
|
+
await mutation.revokeApplianceCredential(issued.id, context())
|
|
158
|
+
const reissued = await mutation.generateApplianceSecret(issued.id, context())
|
|
159
|
+
|
|
160
|
+
expect(await query.credentialRevoked(reissued)).toBe(false)
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
it('does not read one appliance state onto another', async () => {
|
|
164
|
+
revocationOn()
|
|
165
|
+
|
|
166
|
+
const a = await mutation.generateApplianceSecret((await register('gate-f')).id, context())
|
|
167
|
+
const b = await mutation.generateApplianceSecret((await register('gate-g')).id, context())
|
|
168
|
+
|
|
169
|
+
await mutation.revokeApplianceCredential(a.id, context())
|
|
170
|
+
|
|
171
|
+
expect(await query.credentialRevoked(await getRepository(Appliance).findOneBy({ id: a.id }))).toBe(true)
|
|
172
|
+
expect(await query.credentialRevoked(await getRepository(Appliance).findOneBy({ id: b.id }))).toBe(false)
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
it('reads the shadow user rather than the cleared column', async () => {
|
|
176
|
+
/*
|
|
177
|
+
* ⚠ The tempting shortcut is "no access token means revoked". It is wrong in both
|
|
178
|
+
* directions: a registered appliance has no token and is not revoked, and clearing the
|
|
179
|
+
* column by hand does not stop anything — the JWT verifies on its signature.
|
|
180
|
+
*
|
|
181
|
+
* So an appliance whose token is emptied without going through revocation is not revoked,
|
|
182
|
+
* and one whose status is INACTIVE is, whatever the column holds.
|
|
183
|
+
*/
|
|
184
|
+
const issued = await mutation.generateApplianceSecret((await register('gate-h')).id, context())
|
|
185
|
+
|
|
186
|
+
await getRepository(Appliance).save({ ...issued, accessToken: null })
|
|
187
|
+
expect(await query.credentialRevoked(await getRepository(Appliance).findOneBy({ id: issued.id }))).toBe(false)
|
|
188
|
+
|
|
189
|
+
const appuser = await getRepository(User).findOneBy({ reference: issued.id, userType: 'appliance' })
|
|
190
|
+
await getRepository(User).save({ ...appuser, status: UserStatus.INACTIVE })
|
|
191
|
+
|
|
192
|
+
expect(await query.credentialRevoked(await getRepository(Appliance).findOneBy({ id: issued.id }))).toBe(true)
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
it('does not call a locked appliance revoked', async () => {
|
|
196
|
+
/*
|
|
197
|
+
* LOCKED is the failed-sign-in lockout and it ages out by itself, which is why revocation
|
|
198
|
+
* does not use it. Reporting it as revoked would tell the reader a lock is permanent.
|
|
199
|
+
*/
|
|
200
|
+
const issued = await mutation.generateApplianceSecret((await register('gate-i')).id, context())
|
|
201
|
+
|
|
202
|
+
const appuser = await getRepository(User).findOneBy({ reference: issued.id, userType: 'appliance' })
|
|
203
|
+
await getRepository(User).save({ ...appuser, status: UserStatus.LOCKED })
|
|
204
|
+
|
|
205
|
+
expect(await query.credentialRevoked(await getRepository(Appliance).findOneBy({ id: issued.id }))).toBe(false)
|
|
206
|
+
})
|
|
207
|
+
})
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deleting an appliance stays inside the tenant that asked.
|
|
3
|
+
*
|
|
4
|
+
* ── The order the deletes happen in ─────────────────────────────────────────
|
|
5
|
+
* An appliance is two rows: the appliance itself, which carries a domain, and a shadow `User`
|
|
6
|
+
* standing in for the device, which is found by `reference` and carries no tenant of its own.
|
|
7
|
+
*
|
|
8
|
+
* `deleteAppliance` deleted the shadow user first and scoped only the appliance row afterwards.
|
|
9
|
+
* So an id belonging to somebody else deleted their device's identity and then failed to delete
|
|
10
|
+
* their appliance — the row stayed on their screen looking registered, and the device behind it
|
|
11
|
+
* stopped at the next request, because `checkAuth` looks the subject up by the id in the token
|
|
12
|
+
* and there was no longer a subject. Nothing in the answer said so: the mutation returns true.
|
|
13
|
+
*
|
|
14
|
+
* ⚠ sqlite (`tests/db.ts`).
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { closeAuthDatabase, Domain, getRepository, openAuthDatabase, resetAuthDatabase } from './db'
|
|
18
|
+
import { loadCompiled } from './compiled'
|
|
19
|
+
|
|
20
|
+
const { Appliance } = loadCompiled('service/appliance/appliance')
|
|
21
|
+
const { User, UserStatus } = loadCompiled('service/user/user')
|
|
22
|
+
const { ApplianceMutation } = loadCompiled('service/appliance/appliance-mutation')
|
|
23
|
+
|
|
24
|
+
const mutation = new ApplianceMutation()
|
|
25
|
+
|
|
26
|
+
let ours: any
|
|
27
|
+
let theirs: any
|
|
28
|
+
let actor: any
|
|
29
|
+
|
|
30
|
+
function context(d: any) {
|
|
31
|
+
return {
|
|
32
|
+
state: { domain: d, user: actor, tx: undefined },
|
|
33
|
+
throw: (code: number, message: string) => {
|
|
34
|
+
const error: any = new Error(message)
|
|
35
|
+
error.status = code
|
|
36
|
+
throw error
|
|
37
|
+
}
|
|
38
|
+
} as any
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function register(name: string, d: any) {
|
|
42
|
+
return await mutation.createAppliance({ name, serialNo: `${name}-SN`, brand: 'Acme', model: 'X1' }, context(d))
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function shadowUserOf(applianceId: string) {
|
|
46
|
+
return await getRepository(User).findOneBy({ reference: applianceId, userType: 'appliance' })
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
beforeAll(async () => {
|
|
50
|
+
await openAuthDatabase()
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
afterAll(async () => {
|
|
54
|
+
await closeAuthDatabase()
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
beforeEach(async () => {
|
|
58
|
+
await resetAuthDatabase()
|
|
59
|
+
|
|
60
|
+
ours = await getRepository(Domain).save({ name: 'acme', subdomain: 'acme' })
|
|
61
|
+
theirs = await getRepository(Domain).save({ name: 'beta', subdomain: 'beta' })
|
|
62
|
+
actor = await getRepository(User).save({
|
|
63
|
+
username: 'operator@acme.z',
|
|
64
|
+
email: 'operator@acme.z',
|
|
65
|
+
name: 'operator',
|
|
66
|
+
status: UserStatus.ACTIVATED,
|
|
67
|
+
domains: [ours]
|
|
68
|
+
})
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
describe('deleting our own appliance', () => {
|
|
72
|
+
it('takes the appliance and its shadow user', async () => {
|
|
73
|
+
const appliance = await register('gate-a', ours)
|
|
74
|
+
await mutation.generateApplianceSecret(appliance.id, context(ours))
|
|
75
|
+
|
|
76
|
+
await mutation.deleteAppliance(appliance.id, context(ours))
|
|
77
|
+
|
|
78
|
+
expect(await getRepository(Appliance).findOneBy({ id: appliance.id })).toBeNull()
|
|
79
|
+
expect(await shadowUserOf(appliance.id)).toBeNull()
|
|
80
|
+
})
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
describe('an id that belongs to another tenant', () => {
|
|
84
|
+
it('leaves their appliance where it is', async () => {
|
|
85
|
+
const foreign = await register('their-gate', theirs)
|
|
86
|
+
|
|
87
|
+
await mutation.deleteAppliance(foreign.id, context(ours))
|
|
88
|
+
|
|
89
|
+
expect(await getRepository(Appliance).findOneBy({ id: foreign.id })).not.toBeNull()
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
it('leaves their device able to authenticate', async () => {
|
|
93
|
+
/*
|
|
94
|
+
* ⚠ The case. Deleting the shadow user before checking the tenant stopped somebody else's
|
|
95
|
+
* device while leaving their appliance row on screen looking registered — no screen would
|
|
96
|
+
* show what happened, and the fix from their side is to issue a new secret for a device
|
|
97
|
+
* that had a working one.
|
|
98
|
+
*/
|
|
99
|
+
const foreign = await register('their-gate', theirs)
|
|
100
|
+
const issued = await mutation.generateApplianceSecret(foreign.id, context(theirs))
|
|
101
|
+
|
|
102
|
+
await mutation.deleteAppliance(foreign.id, context(ours))
|
|
103
|
+
|
|
104
|
+
expect(await shadowUserOf(foreign.id)).not.toBeNull()
|
|
105
|
+
|
|
106
|
+
const jwt = require('jsonwebtoken')
|
|
107
|
+
await expect(User.checkAuth(jwt.decode(issued.accessToken))).resolves.toBeTruthy()
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
it('does not take ours down with it either way', async () => {
|
|
111
|
+
const mine = await register('gate-a', ours)
|
|
112
|
+
const foreign = await register('their-gate', theirs)
|
|
113
|
+
|
|
114
|
+
await mutation.deleteAppliance(foreign.id, context(ours))
|
|
115
|
+
|
|
116
|
+
expect(await getRepository(Appliance).findOneBy({ id: mine.id })).not.toBeNull()
|
|
117
|
+
expect(await shadowUserOf(mine.id)).not.toBeNull()
|
|
118
|
+
})
|
|
119
|
+
})
|