@things-factory/auth-base 10.1.31 → 10.1.34
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
- package/dist-server/controllers/checkin.d.ts +0 -4
- package/dist-server/controllers/checkin.js +0 -20
- package/dist-server/controllers/checkin.js.map +0 -1
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { ValueTransformer } from 'typeorm';
|
|
2
|
+
/**
|
|
3
|
+
* A credential column that encrypts only where the installation asked for it (ADR-0085).
|
|
4
|
+
*
|
|
5
|
+
* ── The switch governs writing. Reading always takes both ───────────────────
|
|
6
|
+
* `shell`'s `encryptTransformer` reads a value that is not ciphertext-shaped as plaintext, so
|
|
7
|
+
* rows written before the switch was turned on stay readable, and each row converts the next
|
|
8
|
+
* time something writes it. That behaviour does **not** move with the switch — if it did,
|
|
9
|
+
* turning the feature off would lose every row written while it was on.
|
|
10
|
+
*
|
|
11
|
+
* So off means "write it the way we always did", not "pretend the encrypted rows are not
|
|
12
|
+
* there".
|
|
13
|
+
*
|
|
14
|
+
* ── The switch is read per write, not once at import ────────────────────────
|
|
15
|
+
* A module-level constant would freeze whatever the config looked like when the entity file was
|
|
16
|
+
* first loaded, which is not the same thing in tests and not obviously the same thing at boot.
|
|
17
|
+
* `to` asks each time; it is a property lookup.
|
|
18
|
+
*
|
|
19
|
+
* ── Where this is NOT used: `User.password` ─────────────────────────────────
|
|
20
|
+
* That column holds a different thing depending on who the row is. For a person it is an HMAC
|
|
21
|
+
* of their password — already not the secret, so encrypting it protects nothing. For an
|
|
22
|
+
* appliance or an app binding it is the token itself, which is exactly what this is for.
|
|
23
|
+
*
|
|
24
|
+
* A column transformer cannot tell those apart, and encrypting people's hashes to reach the
|
|
25
|
+
* machines' tokens is the wrong trade. The machine credential belongs somewhere of its own;
|
|
26
|
+
* that is the Principal / Credential split, §8 step 4 of `docs/design/auth-rebuild.md`.
|
|
27
|
+
*/
|
|
28
|
+
export declare const credentialAtRestTransformer: ValueTransformer;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.credentialAtRestTransformer = void 0;
|
|
4
|
+
const shell_1 = require("@things-factory/shell");
|
|
5
|
+
const credential_features_js_1 = require("./credential-features.js");
|
|
6
|
+
/**
|
|
7
|
+
* A credential column that encrypts only where the installation asked for it (ADR-0085).
|
|
8
|
+
*
|
|
9
|
+
* ── The switch governs writing. Reading always takes both ───────────────────
|
|
10
|
+
* `shell`'s `encryptTransformer` reads a value that is not ciphertext-shaped as plaintext, so
|
|
11
|
+
* rows written before the switch was turned on stay readable, and each row converts the next
|
|
12
|
+
* time something writes it. That behaviour does **not** move with the switch — if it did,
|
|
13
|
+
* turning the feature off would lose every row written while it was on.
|
|
14
|
+
*
|
|
15
|
+
* So off means "write it the way we always did", not "pretend the encrypted rows are not
|
|
16
|
+
* there".
|
|
17
|
+
*
|
|
18
|
+
* ── The switch is read per write, not once at import ────────────────────────
|
|
19
|
+
* A module-level constant would freeze whatever the config looked like when the entity file was
|
|
20
|
+
* first loaded, which is not the same thing in tests and not obviously the same thing at boot.
|
|
21
|
+
* `to` asks each time; it is a property lookup.
|
|
22
|
+
*
|
|
23
|
+
* ── Where this is NOT used: `User.password` ─────────────────────────────────
|
|
24
|
+
* That column holds a different thing depending on who the row is. For a person it is an HMAC
|
|
25
|
+
* of their password — already not the secret, so encrypting it protects nothing. For an
|
|
26
|
+
* appliance or an app binding it is the token itself, which is exactly what this is for.
|
|
27
|
+
*
|
|
28
|
+
* A column transformer cannot tell those apart, and encrypting people's hashes to reach the
|
|
29
|
+
* machines' tokens is the wrong trade. The machine credential belongs somewhere of its own;
|
|
30
|
+
* that is the Principal / Credential split, §8 step 4 of `docs/design/auth-rebuild.md`.
|
|
31
|
+
*/
|
|
32
|
+
exports.credentialAtRestTransformer = {
|
|
33
|
+
to: (entityValue) => ((0, credential_features_js_1.encryptsCredentialsAtRest)() ? shell_1.encryptTransformer.to(entityValue) : entityValue),
|
|
34
|
+
from: (databaseValue) => shell_1.encryptTransformer.from(databaseValue)
|
|
35
|
+
};
|
|
36
|
+
//# sourceMappingURL=credential-at-rest.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"credential-at-rest.js","sourceRoot":"","sources":["../../server/utils/credential-at-rest.ts"],"names":[],"mappings":";;;AAEA,iDAA0D;AAE1D,qEAAoE;AAEpE;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AACU,QAAA,2BAA2B,GAAqB;IAC3D,EAAE,EAAE,CAAC,WAAmB,EAAE,EAAE,CAAC,CAAC,IAAA,kDAAyB,GAAE,CAAC,CAAC,CAAC,0BAAkB,CAAC,EAAE,CAAC,WAAW,CAAC,CAAC,CAAC,CAAC,WAAW,CAAC;IAE7G,IAAI,EAAE,CAAC,aAAqB,EAAE,EAAE,CAAC,0BAAkB,CAAC,IAAI,CAAC,aAAa,CAAC;CACxE,CAAA","sourcesContent":["import { ValueTransformer } from 'typeorm'\n\nimport { encryptTransformer } from '@things-factory/shell'\n\nimport { encryptsCredentialsAtRest } from './credential-features.js'\n\n/**\n * A credential column that encrypts only where the installation asked for it (ADR-0085).\n *\n * ── The switch governs writing. Reading always takes both ───────────────────\n * `shell`'s `encryptTransformer` reads a value that is not ciphertext-shaped as plaintext, so\n * rows written before the switch was turned on stay readable, and each row converts the next\n * time something writes it. That behaviour does **not** move with the switch — if it did,\n * turning the feature off would lose every row written while it was on.\n *\n * So off means \"write it the way we always did\", not \"pretend the encrypted rows are not\n * there\".\n *\n * ── The switch is read per write, not once at import ────────────────────────\n * A module-level constant would freeze whatever the config looked like when the entity file was\n * first loaded, which is not the same thing in tests and not obviously the same thing at boot.\n * `to` asks each time; it is a property lookup.\n *\n * ── Where this is NOT used: `User.password` ─────────────────────────────────\n * That column holds a different thing depending on who the row is. For a person it is an HMAC\n * of their password — already not the secret, so encrypting it protects nothing. For an\n * appliance or an app binding it is the token itself, which is exactly what this is for.\n *\n * A column transformer cannot tell those apart, and encrypting people's hashes to reach the\n * machines' tokens is the wrong trade. The machine credential belongs somewhere of its own;\n * that is the Principal / Credential split, §8 step 4 of `docs/design/auth-rebuild.md`.\n */\nexport const credentialAtRestTransformer: ValueTransformer = {\n to: (entityValue: string) => (encryptsCredentialsAtRest() ? encryptTransformer.to(entityValue) : entityValue),\n\n from: (databaseValue: string) => encryptTransformer.from(databaseValue)\n}\n"]}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The two credential features an installation turns on for itself (ADR-0085).
|
|
3
|
+
*
|
|
4
|
+
* ── Why they are switches and not just code ─────────────────────────────────
|
|
5
|
+
* An appliance token lives a year, and the ones already out in the field were issued by the
|
|
6
|
+
* version running now. Shipping either feature as plain behaviour would change what those
|
|
7
|
+
* deployments do the moment they take the release. So the installation decides, and a
|
|
8
|
+
* deployment that changes nothing keeps working exactly as it does today.
|
|
9
|
+
*
|
|
10
|
+
* `config.get(key, false)` answers the default when the key is absent, so "not configured"
|
|
11
|
+
* and "off" are the same thing here.
|
|
12
|
+
*
|
|
13
|
+
* ⚠ That `get` treats every falsy value as absent, so writing `false` in the config file also
|
|
14
|
+
* lands on the default. It matches while the default is `false`. **Do not flip these defaults
|
|
15
|
+
* to `true`** — turning them off again would stop working, and the only way off would be to
|
|
16
|
+
* delete the key.
|
|
17
|
+
*/
|
|
18
|
+
/**
|
|
19
|
+
* Store credentials as ciphertext rather than plaintext.
|
|
20
|
+
*
|
|
21
|
+
* ⚠ This governs **writing only**. Reading always accepts both shapes — see
|
|
22
|
+
* `shell/server/typeorm/encrypt-transform.ts`. If reading followed the switch, turning the
|
|
23
|
+
* feature off would lose every row written while it was on.
|
|
24
|
+
*/
|
|
25
|
+
export declare function encryptsCredentialsAtRest(): boolean;
|
|
26
|
+
/**
|
|
27
|
+
* Offer revocation of an issued credential.
|
|
28
|
+
*
|
|
29
|
+
* ⚠ This governs **the door only** — whether the mutation and its screen exist. It does not
|
|
30
|
+
* govern whether an existing revocation holds. A credential revoked while this was on stays
|
|
31
|
+
* revoked after it is turned off, because a lost device must not come back through a config
|
|
32
|
+
* change. The check that enforces it does nothing on its own: a subject that was never
|
|
33
|
+
* revoked carries no serial, and the comparison never fires.
|
|
34
|
+
*/
|
|
35
|
+
export declare function offersCredentialRevocation(): boolean;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.encryptsCredentialsAtRest = encryptsCredentialsAtRest;
|
|
4
|
+
exports.offersCredentialRevocation = offersCredentialRevocation;
|
|
5
|
+
const env_1 = require("@things-factory/env");
|
|
6
|
+
/**
|
|
7
|
+
* The two credential features an installation turns on for itself (ADR-0085).
|
|
8
|
+
*
|
|
9
|
+
* ── Why they are switches and not just code ─────────────────────────────────
|
|
10
|
+
* An appliance token lives a year, and the ones already out in the field were issued by the
|
|
11
|
+
* version running now. Shipping either feature as plain behaviour would change what those
|
|
12
|
+
* deployments do the moment they take the release. So the installation decides, and a
|
|
13
|
+
* deployment that changes nothing keeps working exactly as it does today.
|
|
14
|
+
*
|
|
15
|
+
* `config.get(key, false)` answers the default when the key is absent, so "not configured"
|
|
16
|
+
* and "off" are the same thing here.
|
|
17
|
+
*
|
|
18
|
+
* ⚠ That `get` treats every falsy value as absent, so writing `false` in the config file also
|
|
19
|
+
* lands on the default. It matches while the default is `false`. **Do not flip these defaults
|
|
20
|
+
* to `true`** — turning them off again would stop working, and the only way off would be to
|
|
21
|
+
* delete the key.
|
|
22
|
+
*/
|
|
23
|
+
/**
|
|
24
|
+
* Store credentials as ciphertext rather than plaintext.
|
|
25
|
+
*
|
|
26
|
+
* ⚠ This governs **writing only**. Reading always accepts both shapes — see
|
|
27
|
+
* `shell/server/typeorm/encrypt-transform.ts`. If reading followed the switch, turning the
|
|
28
|
+
* feature off would lose every row written while it was on.
|
|
29
|
+
*/
|
|
30
|
+
function encryptsCredentialsAtRest() {
|
|
31
|
+
return !!env_1.config.get('credential/encryptAtRest', false);
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Offer revocation of an issued credential.
|
|
35
|
+
*
|
|
36
|
+
* ⚠ This governs **the door only** — whether the mutation and its screen exist. It does not
|
|
37
|
+
* govern whether an existing revocation holds. A credential revoked while this was on stays
|
|
38
|
+
* revoked after it is turned off, because a lost device must not come back through a config
|
|
39
|
+
* change. The check that enforces it does nothing on its own: a subject that was never
|
|
40
|
+
* revoked carries no serial, and the comparison never fires.
|
|
41
|
+
*/
|
|
42
|
+
function offersCredentialRevocation() {
|
|
43
|
+
return !!env_1.config.get('credential/revocable', false);
|
|
44
|
+
}
|
|
45
|
+
//# sourceMappingURL=credential-features.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"credential-features.js","sourceRoot":"","sources":["../../server/utils/credential-features.ts"],"names":[],"mappings":";;AA2BA,8DAEC;AAWD,gEAEC;AA1CD,6CAA4C;AAE5C;;;;;;;;;;;;;;;;GAgBG;AAEH;;;;;;GAMG;AACH,SAAgB,yBAAyB;IACvC,OAAO,CAAC,CAAC,YAAM,CAAC,GAAG,CAAC,0BAA0B,EAAE,KAAK,CAAC,CAAA;AACxD,CAAC;AAED;;;;;;;;GAQG;AACH,SAAgB,0BAA0B;IACxC,OAAO,CAAC,CAAC,YAAM,CAAC,GAAG,CAAC,sBAAsB,EAAE,KAAK,CAAC,CAAA;AACpD,CAAC","sourcesContent":["import { config } from '@things-factory/env'\n\n/**\n * The two credential features an installation turns on for itself (ADR-0085).\n *\n * ── Why they are switches and not just code ─────────────────────────────────\n * An appliance token lives a year, and the ones already out in the field were issued by the\n * version running now. Shipping either feature as plain behaviour would change what those\n * deployments do the moment they take the release. So the installation decides, and a\n * deployment that changes nothing keeps working exactly as it does today.\n *\n * `config.get(key, false)` answers the default when the key is absent, so \"not configured\"\n * and \"off\" are the same thing here.\n *\n * ⚠ That `get` treats every falsy value as absent, so writing `false` in the config file also\n * lands on the default. It matches while the default is `false`. **Do not flip these defaults\n * to `true`** — turning them off again would stop working, and the only way off would be to\n * delete the key.\n */\n\n/**\n * Store credentials as ciphertext rather than plaintext.\n *\n * ⚠ This governs **writing only**. Reading always accepts both shapes — see\n * `shell/server/typeorm/encrypt-transform.ts`. If reading followed the switch, turning the\n * feature off would lose every row written while it was on.\n */\nexport function encryptsCredentialsAtRest(): boolean {\n return !!config.get('credential/encryptAtRest', false)\n}\n\n/**\n * Offer revocation of an issued credential.\n *\n * ⚠ This governs **the door only** — whether the mutation and its screen exist. It does not\n * govern whether an existing revocation holds. A credential revoked while this was on stays\n * revoked after it is turned off, because a lost device must not come back through a config\n * change. The check that enforces it does nothing on its own: a subject that was never\n * revoked carries no serial, and the comparison never fires.\n */\nexport function offersCredentialRevocation(): boolean {\n return !!config.get('credential/revocable', false)\n}\n"]}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Does this token still name the current generation of its subject's credential? (Pure rule.)
|
|
3
|
+
*
|
|
4
|
+
* ## Why the rule is its own function
|
|
5
|
+
*
|
|
6
|
+
* `checkAuth` runs on every request, and this is the one line in it that can refuse a token
|
|
7
|
+
* whose signature is perfectly good. A rule that decides that belongs somewhere it can be read
|
|
8
|
+
* and tested on its own, next to `account-lock-rule` and `checkin-domain-rule`.
|
|
9
|
+
*
|
|
10
|
+
* ## What an absent serial means, and why it must pass
|
|
11
|
+
*
|
|
12
|
+
* An appliance token lives a year. The ones in the field were signed before the column existed,
|
|
13
|
+
* so they carry no serial, and the subjects they name have none recorded. **Those have to pass**
|
|
14
|
+
* — otherwise taking the release cuts off every device at once, and the point of the design was
|
|
15
|
+
* that it does not (ADR-0085 decision 3).
|
|
16
|
+
*
|
|
17
|
+
* A subject gains a serial the first time its credential is issued or revoked. From that moment
|
|
18
|
+
* its tokens have to carry the same number, and an older one no longer does.
|
|
19
|
+
*
|
|
20
|
+
* ## Why this closes what a status could not
|
|
21
|
+
*
|
|
22
|
+
* Revocation used to live entirely in the shadow user's status, which meant putting the status
|
|
23
|
+
* back let every token ever issued to that appliance work again. The serial moves on revoke and
|
|
24
|
+
* never moves back, so reactivating a subject does not resurrect its old credentials — only
|
|
25
|
+
* issuing a new one does, and that one carries the new number.
|
|
26
|
+
*/
|
|
27
|
+
/** The parts of the subject this rule reads. */
|
|
28
|
+
export type CredentialSubject = {
|
|
29
|
+
credentialSerial?: number | null;
|
|
30
|
+
};
|
|
31
|
+
/** The parts of a verified token this rule reads. `cs` is absent on anything issued earlier. */
|
|
32
|
+
export type CredentialClaims = {
|
|
33
|
+
cs?: number | null;
|
|
34
|
+
};
|
|
35
|
+
/**
|
|
36
|
+
* True when the token may still be used for this subject.
|
|
37
|
+
*
|
|
38
|
+
* Absent-on-the-subject means this subject has never been through the new path, and everything
|
|
39
|
+
* passes. Present means the token has to match it exactly.
|
|
40
|
+
*/
|
|
41
|
+
export declare function credentialSerialMatches(subject: CredentialSubject, claims: CredentialClaims): boolean;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Does this token still name the current generation of its subject's credential? (Pure rule.)
|
|
4
|
+
*
|
|
5
|
+
* ## Why the rule is its own function
|
|
6
|
+
*
|
|
7
|
+
* `checkAuth` runs on every request, and this is the one line in it that can refuse a token
|
|
8
|
+
* whose signature is perfectly good. A rule that decides that belongs somewhere it can be read
|
|
9
|
+
* and tested on its own, next to `account-lock-rule` and `checkin-domain-rule`.
|
|
10
|
+
*
|
|
11
|
+
* ## What an absent serial means, and why it must pass
|
|
12
|
+
*
|
|
13
|
+
* An appliance token lives a year. The ones in the field were signed before the column existed,
|
|
14
|
+
* so they carry no serial, and the subjects they name have none recorded. **Those have to pass**
|
|
15
|
+
* — otherwise taking the release cuts off every device at once, and the point of the design was
|
|
16
|
+
* that it does not (ADR-0085 decision 3).
|
|
17
|
+
*
|
|
18
|
+
* A subject gains a serial the first time its credential is issued or revoked. From that moment
|
|
19
|
+
* its tokens have to carry the same number, and an older one no longer does.
|
|
20
|
+
*
|
|
21
|
+
* ## Why this closes what a status could not
|
|
22
|
+
*
|
|
23
|
+
* Revocation used to live entirely in the shadow user's status, which meant putting the status
|
|
24
|
+
* back let every token ever issued to that appliance work again. The serial moves on revoke and
|
|
25
|
+
* never moves back, so reactivating a subject does not resurrect its old credentials — only
|
|
26
|
+
* issuing a new one does, and that one carries the new number.
|
|
27
|
+
*/
|
|
28
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
29
|
+
exports.credentialSerialMatches = credentialSerialMatches;
|
|
30
|
+
/**
|
|
31
|
+
* True when the token may still be used for this subject.
|
|
32
|
+
*
|
|
33
|
+
* Absent-on-the-subject means this subject has never been through the new path, and everything
|
|
34
|
+
* passes. Present means the token has to match it exactly.
|
|
35
|
+
*/
|
|
36
|
+
function credentialSerialMatches(subject, claims) {
|
|
37
|
+
const current = subject?.credentialSerial;
|
|
38
|
+
if (current === null || current === undefined) {
|
|
39
|
+
return true;
|
|
40
|
+
}
|
|
41
|
+
return claims?.cs === current;
|
|
42
|
+
}
|
|
43
|
+
//# sourceMappingURL=credential-serial-rule.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"credential-serial-rule.js","sourceRoot":"","sources":["../../server/utils/credential-serial-rule.ts"],"names":[],"mappings":";AAAA;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;;AAkBH,0DAQC;AAdD;;;;;GAKG;AACH,SAAgB,uBAAuB,CAAC,OAA0B,EAAE,MAAwB;IAC1F,MAAM,OAAO,GAAG,OAAO,EAAE,gBAAgB,CAAA;IAEzC,IAAI,OAAO,KAAK,IAAI,IAAI,OAAO,KAAK,SAAS,EAAE,CAAC;QAC9C,OAAO,IAAI,CAAA;IACb,CAAC;IAED,OAAO,MAAM,EAAE,EAAE,KAAK,OAAO,CAAA;AAC/B,CAAC","sourcesContent":["/**\n * Does this token still name the current generation of its subject's credential? (Pure rule.)\n *\n * ## Why the rule is its own function\n *\n * `checkAuth` runs on every request, and this is the one line in it that can refuse a token\n * whose signature is perfectly good. A rule that decides that belongs somewhere it can be read\n * and tested on its own, next to `account-lock-rule` and `checkin-domain-rule`.\n *\n * ## What an absent serial means, and why it must pass\n *\n * An appliance token lives a year. The ones in the field were signed before the column existed,\n * so they carry no serial, and the subjects they name have none recorded. **Those have to pass**\n * — otherwise taking the release cuts off every device at once, and the point of the design was\n * that it does not (ADR-0085 decision 3).\n *\n * A subject gains a serial the first time its credential is issued or revoked. From that moment\n * its tokens have to carry the same number, and an older one no longer does.\n *\n * ## Why this closes what a status could not\n *\n * Revocation used to live entirely in the shadow user's status, which meant putting the status\n * back let every token ever issued to that appliance work again. The serial moves on revoke and\n * never moves back, so reactivating a subject does not resurrect its old credentials — only\n * issuing a new one does, and that one carries the new number.\n */\n\n/** The parts of the subject this rule reads. */\nexport type CredentialSubject = {\n credentialSerial?: number | null\n}\n\n/** The parts of a verified token this rule reads. `cs` is absent on anything issued earlier. */\nexport type CredentialClaims = {\n cs?: number | null\n}\n\n/**\n * True when the token may still be used for this subject.\n *\n * Absent-on-the-subject means this subject has never been through the new path, and everything\n * passes. Present means the token has to match it exactly.\n */\nexport function credentialSerialMatches(subject: CredentialSubject, claims: CredentialClaims): boolean {\n const current = subject?.credentialSerial\n\n if (current === null || current === undefined) {\n return true\n }\n\n return claims?.cs === current\n}\n"]}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What state an invitation is in, and whether it can still be accepted. (Pure rule.)
|
|
3
|
+
*
|
|
4
|
+
* ## Why the table keeps rows now
|
|
5
|
+
*
|
|
6
|
+
* Accepting used to delete the row and cancelling still does, so the table was a queue of
|
|
7
|
+
* pending invitations and nothing else. The moment somebody joined, the fact that they had been
|
|
8
|
+
* invited stopped existing — which is why no screen was ever built on it, and why nothing calls
|
|
9
|
+
* any of it. An invitation list has to be able to say *who was invited, by whom, when, and
|
|
10
|
+
* whether they came in*.
|
|
11
|
+
*
|
|
12
|
+
* So the row survives and carries its state. `pending` → one of the other three, and never back.
|
|
13
|
+
*
|
|
14
|
+
* ## Why expiry is derived and not only stored
|
|
15
|
+
*
|
|
16
|
+
* A token that outlives its invitation is a credential nobody is watching. If `expired` were
|
|
17
|
+
* only ever a written value, it would depend on something running to write it, and the gap
|
|
18
|
+
* between the expiry passing and that thing running is a window where the token still works.
|
|
19
|
+
* So the rule reads the clock: a pending row past its `expiresAt` **is** expired, whatever the
|
|
20
|
+
* column says. Writing the status is then bookkeeping for the list, not the guard.
|
|
21
|
+
*
|
|
22
|
+
* ## What an absent value means, and why it refuses
|
|
23
|
+
*
|
|
24
|
+
* Rows written before this existed have neither status nor expiry. They come from a flow that
|
|
25
|
+
* never completed — the link in the email led nowhere — so treating them as live would keep
|
|
26
|
+
* tokens alive that were never usable in the first place.
|
|
27
|
+
*
|
|
28
|
+
* status absent reads as `pending`, which is what the old table meant by a row existing
|
|
29
|
+
* expiresAt absent reads as **expired**. An invitation that cannot say when it dies is not
|
|
30
|
+
* a live invitation, and this is a credential: absence refuses.
|
|
31
|
+
*/
|
|
32
|
+
/** The states an invitation row can be in. Stored, and also derived for expiry. */
|
|
33
|
+
export declare enum InvitationStatus {
|
|
34
|
+
PENDING = "pending",
|
|
35
|
+
ACCEPTED = "accepted",
|
|
36
|
+
CANCELLED = "cancelled",
|
|
37
|
+
EXPIRED = "expired"
|
|
38
|
+
}
|
|
39
|
+
/** The parts of an invitation this rule reads. */
|
|
40
|
+
export type InvitationRecord = {
|
|
41
|
+
status?: string | null;
|
|
42
|
+
expiresAt?: Date | string | null;
|
|
43
|
+
} | null | undefined;
|
|
44
|
+
/**
|
|
45
|
+
* The state this invitation is in, as of `now`.
|
|
46
|
+
*
|
|
47
|
+
* Only a pending row is measured against the clock. Once a row has been accepted or cancelled
|
|
48
|
+
* that is what happened to it, and time passing does not rewrite history.
|
|
49
|
+
*/
|
|
50
|
+
export declare function invitationState(invitation: InvitationRecord, now?: Date): InvitationStatus;
|
|
51
|
+
/** May this invitation still be accepted? Only a pending one that has not run out. */
|
|
52
|
+
export declare function mayAccept(invitation: InvitationRecord, now?: Date): boolean;
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* What state an invitation is in, and whether it can still be accepted. (Pure rule.)
|
|
4
|
+
*
|
|
5
|
+
* ## Why the table keeps rows now
|
|
6
|
+
*
|
|
7
|
+
* Accepting used to delete the row and cancelling still does, so the table was a queue of
|
|
8
|
+
* pending invitations and nothing else. The moment somebody joined, the fact that they had been
|
|
9
|
+
* invited stopped existing — which is why no screen was ever built on it, and why nothing calls
|
|
10
|
+
* any of it. An invitation list has to be able to say *who was invited, by whom, when, and
|
|
11
|
+
* whether they came in*.
|
|
12
|
+
*
|
|
13
|
+
* So the row survives and carries its state. `pending` → one of the other three, and never back.
|
|
14
|
+
*
|
|
15
|
+
* ## Why expiry is derived and not only stored
|
|
16
|
+
*
|
|
17
|
+
* A token that outlives its invitation is a credential nobody is watching. If `expired` were
|
|
18
|
+
* only ever a written value, it would depend on something running to write it, and the gap
|
|
19
|
+
* between the expiry passing and that thing running is a window where the token still works.
|
|
20
|
+
* So the rule reads the clock: a pending row past its `expiresAt` **is** expired, whatever the
|
|
21
|
+
* column says. Writing the status is then bookkeeping for the list, not the guard.
|
|
22
|
+
*
|
|
23
|
+
* ## What an absent value means, and why it refuses
|
|
24
|
+
*
|
|
25
|
+
* Rows written before this existed have neither status nor expiry. They come from a flow that
|
|
26
|
+
* never completed — the link in the email led nowhere — so treating them as live would keep
|
|
27
|
+
* tokens alive that were never usable in the first place.
|
|
28
|
+
*
|
|
29
|
+
* status absent reads as `pending`, which is what the old table meant by a row existing
|
|
30
|
+
* expiresAt absent reads as **expired**. An invitation that cannot say when it dies is not
|
|
31
|
+
* a live invitation, and this is a credential: absence refuses.
|
|
32
|
+
*/
|
|
33
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
34
|
+
exports.InvitationStatus = void 0;
|
|
35
|
+
exports.invitationState = invitationState;
|
|
36
|
+
exports.mayAccept = mayAccept;
|
|
37
|
+
/** The states an invitation row can be in. Stored, and also derived for expiry. */
|
|
38
|
+
var InvitationStatus;
|
|
39
|
+
(function (InvitationStatus) {
|
|
40
|
+
InvitationStatus["PENDING"] = "pending";
|
|
41
|
+
InvitationStatus["ACCEPTED"] = "accepted";
|
|
42
|
+
InvitationStatus["CANCELLED"] = "cancelled";
|
|
43
|
+
InvitationStatus["EXPIRED"] = "expired";
|
|
44
|
+
})(InvitationStatus || (exports.InvitationStatus = InvitationStatus = {}));
|
|
45
|
+
function expiryPassed(expiresAt, now) {
|
|
46
|
+
if (!expiresAt) {
|
|
47
|
+
/* No expiry recorded — see the note above. Absence refuses. */
|
|
48
|
+
return true;
|
|
49
|
+
}
|
|
50
|
+
const at = expiresAt instanceof Date ? expiresAt : new Date(expiresAt);
|
|
51
|
+
if (Number.isNaN(at.getTime())) {
|
|
52
|
+
return true;
|
|
53
|
+
}
|
|
54
|
+
return at.getTime() <= now.getTime();
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* The state this invitation is in, as of `now`.
|
|
58
|
+
*
|
|
59
|
+
* Only a pending row is measured against the clock. Once a row has been accepted or cancelled
|
|
60
|
+
* that is what happened to it, and time passing does not rewrite history.
|
|
61
|
+
*/
|
|
62
|
+
function invitationState(invitation, now = new Date()) {
|
|
63
|
+
const status = invitation?.status || InvitationStatus.PENDING;
|
|
64
|
+
if (status === InvitationStatus.ACCEPTED || status === InvitationStatus.CANCELLED) {
|
|
65
|
+
return status;
|
|
66
|
+
}
|
|
67
|
+
if (status === InvitationStatus.EXPIRED) {
|
|
68
|
+
return InvitationStatus.EXPIRED;
|
|
69
|
+
}
|
|
70
|
+
return expiryPassed(invitation?.expiresAt, now) ? InvitationStatus.EXPIRED : InvitationStatus.PENDING;
|
|
71
|
+
}
|
|
72
|
+
/** May this invitation still be accepted? Only a pending one that has not run out. */
|
|
73
|
+
function mayAccept(invitation, now = new Date()) {
|
|
74
|
+
return !!invitation && invitationState(invitation, now) === InvitationStatus.PENDING;
|
|
75
|
+
}
|
|
76
|
+
//# sourceMappingURL=invitation-state.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"invitation-state.js","sourceRoot":"","sources":["../../server/utils/invitation-state.ts"],"names":[],"mappings":";AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8BG;;;AAqCH,0CAYC;AAGD,8BAEC;AApDD,mFAAmF;AACnF,IAAY,gBAKX;AALD,WAAY,gBAAgB;IAC1B,uCAAmB,CAAA;IACnB,yCAAqB,CAAA;IACrB,2CAAuB,CAAA;IACvB,uCAAmB,CAAA;AACrB,CAAC,EALW,gBAAgB,gCAAhB,gBAAgB,QAK3B;AAQD,SAAS,YAAY,CAAC,SAA2C,EAAE,GAAS;IAC1E,IAAI,CAAC,SAAS,EAAE,CAAC;QACf,+DAA+D;QAC/D,OAAO,IAAI,CAAA;IACb,CAAC;IAED,MAAM,EAAE,GAAG,SAAS,YAAY,IAAI,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,IAAI,IAAI,CAAC,SAAS,CAAC,CAAA;IAEtE,IAAI,MAAM,CAAC,KAAK,CAAC,EAAE,CAAC,OAAO,EAAE,CAAC,EAAE,CAAC;QAC/B,OAAO,IAAI,CAAA;IACb,CAAC;IAED,OAAO,EAAE,CAAC,OAAO,EAAE,IAAI,GAAG,CAAC,OAAO,EAAE,CAAA;AACtC,CAAC;AAED;;;;;GAKG;AACH,SAAgB,eAAe,CAAC,UAA4B,EAAE,MAAY,IAAI,IAAI,EAAE;IAClF,MAAM,MAAM,GAAG,UAAU,EAAE,MAAM,IAAI,gBAAgB,CAAC,OAAO,CAAA;IAE7D,IAAI,MAAM,KAAK,gBAAgB,CAAC,QAAQ,IAAI,MAAM,KAAK,gBAAgB,CAAC,SAAS,EAAE,CAAC;QAClF,OAAO,MAA0B,CAAA;IACnC,CAAC;IAED,IAAI,MAAM,KAAK,gBAAgB,CAAC,OAAO,EAAE,CAAC;QACxC,OAAO,gBAAgB,CAAC,OAAO,CAAA;IACjC,CAAC;IAED,OAAO,YAAY,CAAC,UAAU,EAAE,SAAS,EAAE,GAAG,CAAC,CAAC,CAAC,CAAC,gBAAgB,CAAC,OAAO,CAAC,CAAC,CAAC,gBAAgB,CAAC,OAAO,CAAA;AACvG,CAAC;AAED,sFAAsF;AACtF,SAAgB,SAAS,CAAC,UAA4B,EAAE,MAAY,IAAI,IAAI,EAAE;IAC5E,OAAO,CAAC,CAAC,UAAU,IAAI,eAAe,CAAC,UAAU,EAAE,GAAG,CAAC,KAAK,gBAAgB,CAAC,OAAO,CAAA;AACtF,CAAC","sourcesContent":["/**\n * What state an invitation is in, and whether it can still be accepted. (Pure rule.)\n *\n * ## Why the table keeps rows now\n *\n * Accepting used to delete the row and cancelling still does, so the table was a queue of\n * pending invitations and nothing else. The moment somebody joined, the fact that they had been\n * invited stopped existing — which is why no screen was ever built on it, and why nothing calls\n * any of it. An invitation list has to be able to say *who was invited, by whom, when, and\n * whether they came in*.\n *\n * So the row survives and carries its state. `pending` → one of the other three, and never back.\n *\n * ## Why expiry is derived and not only stored\n *\n * A token that outlives its invitation is a credential nobody is watching. If `expired` were\n * only ever a written value, it would depend on something running to write it, and the gap\n * between the expiry passing and that thing running is a window where the token still works.\n * So the rule reads the clock: a pending row past its `expiresAt` **is** expired, whatever the\n * column says. Writing the status is then bookkeeping for the list, not the guard.\n *\n * ## What an absent value means, and why it refuses\n *\n * Rows written before this existed have neither status nor expiry. They come from a flow that\n * never completed — the link in the email led nowhere — so treating them as live would keep\n * tokens alive that were never usable in the first place.\n *\n * status absent reads as `pending`, which is what the old table meant by a row existing\n * expiresAt absent reads as **expired**. An invitation that cannot say when it dies is not\n * a live invitation, and this is a credential: absence refuses.\n */\n\n/** The states an invitation row can be in. Stored, and also derived for expiry. */\nexport enum InvitationStatus {\n PENDING = 'pending',\n ACCEPTED = 'accepted',\n CANCELLED = 'cancelled',\n EXPIRED = 'expired'\n}\n\n/** The parts of an invitation this rule reads. */\nexport type InvitationRecord = {\n status?: string | null\n expiresAt?: Date | string | null\n} | null | undefined\n\nfunction expiryPassed(expiresAt: Date | string | null | undefined, now: Date): boolean {\n if (!expiresAt) {\n /* No expiry recorded — see the note above. Absence refuses. */\n return true\n }\n\n const at = expiresAt instanceof Date ? expiresAt : new Date(expiresAt)\n\n if (Number.isNaN(at.getTime())) {\n return true\n }\n\n return at.getTime() <= now.getTime()\n}\n\n/**\n * The state this invitation is in, as of `now`.\n *\n * Only a pending row is measured against the clock. Once a row has been accepted or cancelled\n * that is what happened to it, and time passing does not rewrite history.\n */\nexport function invitationState(invitation: InvitationRecord, now: Date = new Date()): InvitationStatus {\n const status = invitation?.status || InvitationStatus.PENDING\n\n if (status === InvitationStatus.ACCEPTED || status === InvitationStatus.CANCELLED) {\n return status as InvitationStatus\n }\n\n if (status === InvitationStatus.EXPIRED) {\n return InvitationStatus.EXPIRED\n }\n\n return expiryPassed(invitation?.expiresAt, now) ? InvitationStatus.EXPIRED : InvitationStatus.PENDING\n}\n\n/** May this invitation still be accepted? Only a pending one that has not run out. */\nexport function mayAccept(invitation: InvitationRecord, now: Date = new Date()): boolean {\n return !!invitation && invitationState(invitation, now) === InvitationStatus.PENDING\n}\n"]}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { EntityManager, Repository, SelectQueryBuilder } from 'typeorm';
|
|
2
|
+
import { Role } from '../service/role/role.js';
|
|
3
|
+
import { type AskingDomain } from './role-name-standing.js';
|
|
4
|
+
/**
|
|
5
|
+
* The one place a role name is checked before it is written (ADR-0062 decision 3).
|
|
6
|
+
*
|
|
7
|
+
* ── Why it is one place ─────────────────────────────────────────────────────
|
|
8
|
+
* Five sites write a role name — `createRole`, the rename in `updateRole`, both halves of
|
|
9
|
+
* `updateMultipleRoles`, and `createRoleFromTemplate`. All five asked `domain: { id: domain.id }`
|
|
10
|
+
* and none of them looked at what the domain inherits. Five copies of a check is five chances
|
|
11
|
+
* for one of them to keep the old question.
|
|
12
|
+
*
|
|
13
|
+
* ── Why the refusal names the domain ────────────────────────────────────────
|
|
14
|
+
* The operator's next step differs by where the name stands. If it stands here they rename; if
|
|
15
|
+
* it is inherited they may decide this domain stands its own. They cannot choose without being
|
|
16
|
+
* told which it is and from where — a refusal that only says "duplicated" leaves them guessing
|
|
17
|
+
* at a domain they may not have known existed.
|
|
18
|
+
*
|
|
19
|
+
* ── Why a `Refusal` and not a sentence ──────────────────────────────────────
|
|
20
|
+
* The rest of this file's neighbours throw English strings, which puts English on a Korean
|
|
21
|
+
* screen and makes the screen write the reason itself (ADR-0054). This refusal is new, so it
|
|
22
|
+
* starts as a name and values; the older sentences around it are left alone.
|
|
23
|
+
*/
|
|
24
|
+
export declare function refuseRoleNameIfStanding({ name, domain, separately, tx }: {
|
|
25
|
+
name: string;
|
|
26
|
+
domain: AskingDomain;
|
|
27
|
+
separately?: boolean;
|
|
28
|
+
tx?: EntityManager;
|
|
29
|
+
}): Promise<void>;
|
|
30
|
+
/**
|
|
31
|
+
* The read behind the check: a role of this name **in one named domain**.
|
|
32
|
+
*
|
|
33
|
+
* ── Why this is a builder and not a `findOne` ───────────────────────────────
|
|
34
|
+
* One domain at a time is the whole point, and the harness that would catch it going back to
|
|
35
|
+
* both is sqlite — which returns the rows in an order that happens to be the right answer, so it
|
|
36
|
+
* cannot fail the way a server does. The repository's own note says as much about column
|
|
37
|
+
* dialects, and `domain-inheritance-sentinel.test.ts` says it about this exact class.
|
|
38
|
+
*
|
|
39
|
+
* So the query is something a test can read **before it is sent**. What we build is the same on
|
|
40
|
+
* every driver, which is the only way to reach a defect our sqlite development cannot see.
|
|
41
|
+
*/
|
|
42
|
+
export declare function standingRoleQuery(repository: Repository<Role>, name: string, domainId: string): SelectQueryBuilder<Role>;
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.refuseRoleNameIfStanding = refuseRoleNameIfStanding;
|
|
4
|
+
exports.standingRoleQuery = standingRoleQuery;
|
|
5
|
+
const shell_1 = require("@things-factory/shell");
|
|
6
|
+
const role_js_1 = require("../service/role/role.js");
|
|
7
|
+
const role_name_standing_js_1 = require("./role-name-standing.js");
|
|
8
|
+
/**
|
|
9
|
+
* The one place a role name is checked before it is written (ADR-0062 decision 3).
|
|
10
|
+
*
|
|
11
|
+
* ── Why it is one place ─────────────────────────────────────────────────────
|
|
12
|
+
* Five sites write a role name — `createRole`, the rename in `updateRole`, both halves of
|
|
13
|
+
* `updateMultipleRoles`, and `createRoleFromTemplate`. All five asked `domain: { id: domain.id }`
|
|
14
|
+
* and none of them looked at what the domain inherits. Five copies of a check is five chances
|
|
15
|
+
* for one of them to keep the old question.
|
|
16
|
+
*
|
|
17
|
+
* ── Why the refusal names the domain ────────────────────────────────────────
|
|
18
|
+
* The operator's next step differs by where the name stands. If it stands here they rename; if
|
|
19
|
+
* it is inherited they may decide this domain stands its own. They cannot choose without being
|
|
20
|
+
* told which it is and from where — a refusal that only says "duplicated" leaves them guessing
|
|
21
|
+
* at a domain they may not have known existed.
|
|
22
|
+
*
|
|
23
|
+
* ── Why a `Refusal` and not a sentence ──────────────────────────────────────
|
|
24
|
+
* The rest of this file's neighbours throw English strings, which puts English on a Korean
|
|
25
|
+
* screen and makes the screen write the reason itself (ADR-0054). This refusal is new, so it
|
|
26
|
+
* starts as a name and values; the older sentences around it are left alone.
|
|
27
|
+
*/
|
|
28
|
+
async function refuseRoleNameIfStanding({ name, domain, separately, tx }) {
|
|
29
|
+
const repository = (0, shell_1.getRepository)(role_js_1.Role, tx);
|
|
30
|
+
/*
|
|
31
|
+
* ⚠ This domain is asked first, on its own, and the parent only if it has nothing.
|
|
32
|
+
*
|
|
33
|
+
* One read over both with `In([own, parent])` has no `ORDER BY`, so when the name stands in
|
|
34
|
+
* **both** the answer is whichever row the driver hands back. If that is the parent's, the
|
|
35
|
+
* refusal is the inherited one — and `separately` is allowed to pass that, which stands a
|
|
36
|
+
* second row of the name in a domain that already has it. Exactly the state the rule exists to
|
|
37
|
+
* prevent, reached through the rule.
|
|
38
|
+
*
|
|
39
|
+
* Standing in both is not a corner: the migration of ADR-0062 decision 2 passes through it for
|
|
40
|
+
* its whole length, because the domain's own role stands up first and the parent is cut last.
|
|
41
|
+
*
|
|
42
|
+
* This is the same defect that `role(name)` had and the same fix. It went back in here because
|
|
43
|
+
* "one read" looked like the tidier shape.
|
|
44
|
+
*/
|
|
45
|
+
const own = await standingRoleQuery(repository, name, domain.id).getOne();
|
|
46
|
+
const found = own || (domain.parentId ? await standingRoleQuery(repository, name, domain.parentId).getOne() : null);
|
|
47
|
+
const standing = (0, role_name_standing_js_1.nameStands)({ domainId: found?.domain?.id || found?.domainId }, domain);
|
|
48
|
+
if (!(0, role_name_standing_js_1.refusesName)(standing, separately)) {
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
if (standing === 'here') {
|
|
52
|
+
throw new shell_1.Refusal('role-name-taken-here', { name }, `This domain already has a role called "${name}".`);
|
|
53
|
+
}
|
|
54
|
+
throw new shell_1.Refusal('role-name-stands-inherited', { name, domain: found?.domain?.name || '' }, `A role called "${name}" is already visible here, defined in "${found?.domain?.name}". ` +
|
|
55
|
+
`Stand this domain's own of that name only if that is what you mean to do.`);
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* The read behind the check: a role of this name **in one named domain**.
|
|
59
|
+
*
|
|
60
|
+
* ── Why this is a builder and not a `findOne` ───────────────────────────────
|
|
61
|
+
* One domain at a time is the whole point, and the harness that would catch it going back to
|
|
62
|
+
* both is sqlite — which returns the rows in an order that happens to be the right answer, so it
|
|
63
|
+
* cannot fail the way a server does. The repository's own note says as much about column
|
|
64
|
+
* dialects, and `domain-inheritance-sentinel.test.ts` says it about this exact class.
|
|
65
|
+
*
|
|
66
|
+
* So the query is something a test can read **before it is sent**. What we build is the same on
|
|
67
|
+
* every driver, which is the only way to reach a defect our sqlite development cannot see.
|
|
68
|
+
*/
|
|
69
|
+
function standingRoleQuery(repository, name, domainId) {
|
|
70
|
+
return repository
|
|
71
|
+
.createQueryBuilder('ROLE')
|
|
72
|
+
.leftJoinAndSelect('ROLE.domain', 'ROLE_DOMAIN')
|
|
73
|
+
.where('ROLE.name = :name', { name })
|
|
74
|
+
.andWhere('ROLE_DOMAIN.id = :domainId', { domainId });
|
|
75
|
+
}
|
|
76
|
+
//# sourceMappingURL=refuse-role-name.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"refuse-role-name.js","sourceRoot":"","sources":["../../server/utils/refuse-role-name.ts"],"names":[],"mappings":";;AA2BA,4DAoDC;AAcD,8CAUC;AArGD,iDAA8D;AAE9D,qDAA8C;AAC9C,mEAAoF;AAEpF;;;;;;;;;;;;;;;;;;;GAmBG;AACI,KAAK,UAAU,wBAAwB,CAAC,EAC7C,IAAI,EACJ,MAAM,EACN,UAAU,EACV,EAAE,EAMH;IACC,MAAM,UAAU,GAAG,IAAA,qBAAa,EAAC,cAAI,EAAE,EAAE,CAAC,CAAA;IAE1C;;;;;;;;;;;;;;OAcG;IACH,MAAM,GAAG,GAAG,MAAM,iBAAiB,CAAC,UAAU,EAAE,IAAI,EAAE,MAAM,CAAC,EAAE,CAAC,CAAC,MAAM,EAAE,CAAA;IAEzE,MAAM,KAAK,GAAG,GAAG,IAAI,CAAC,MAAM,CAAC,QAAQ,CAAC,CAAC,CAAC,MAAM,iBAAiB,CAAC,UAAU,EAAE,IAAI,EAAE,MAAM,CAAC,QAAQ,CAAC,CAAC,MAAM,EAAE,CAAC,CAAC,CAAC,IAAI,CAAC,CAAA;IAEnH,MAAM,QAAQ,GAAG,IAAA,kCAAU,EAAC,EAAE,QAAQ,EAAE,KAAK,EAAE,MAAM,EAAE,EAAE,IAAI,KAAK,EAAE,QAAQ,EAAE,EAAE,MAAM,CAAC,CAAA;IAEvF,IAAI,CAAC,IAAA,mCAAW,EAAC,QAAQ,EAAE,UAAU,CAAC,EAAE,CAAC;QACvC,OAAM;IACR,CAAC;IAED,IAAI,QAAQ,KAAK,MAAM,EAAE,CAAC;QACxB,MAAM,IAAI,eAAO,CACf,sBAAsB,EACtB,EAAE,IAAI,EAAE,EACR,0CAA0C,IAAI,IAAI,CACnD,CAAA;IACH,CAAC;IAED,MAAM,IAAI,eAAO,CACf,4BAA4B,EAC5B,EAAE,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,IAAI,IAAI,EAAE,EAAE,EAC3C,kBAAkB,IAAI,0CAA0C,KAAK,EAAE,MAAM,EAAE,IAAI,KAAK;QACtF,2EAA2E,CAC9E,CAAA;AACH,CAAC;AAED;;;;;;;;;;;GAWG;AACH,SAAgB,iBAAiB,CAC/B,UAA4B,EAC5B,IAAY,EACZ,QAAgB;IAEhB,OAAO,UAAU;SACd,kBAAkB,CAAC,MAAM,CAAC;SAC1B,iBAAiB,CAAC,aAAa,EAAE,aAAa,CAAC;SAC/C,KAAK,CAAC,mBAAmB,EAAE,EAAE,IAAI,EAAE,CAAC;SACpC,QAAQ,CAAC,4BAA4B,EAAE,EAAE,QAAQ,EAAE,CAAC,CAAA;AACzD,CAAC","sourcesContent":["import { EntityManager, Repository, SelectQueryBuilder } from 'typeorm'\n\nimport { getRepository, Refusal } from '@things-factory/shell'\n\nimport { Role } from '../service/role/role.js'\nimport { nameStands, refusesName, type AskingDomain } from './role-name-standing.js'\n\n/**\n * The one place a role name is checked before it is written (ADR-0062 decision 3).\n *\n * ── Why it is one place ─────────────────────────────────────────────────────\n * Five sites write a role name — `createRole`, the rename in `updateRole`, both halves of\n * `updateMultipleRoles`, and `createRoleFromTemplate`. All five asked `domain: { id: domain.id }`\n * and none of them looked at what the domain inherits. Five copies of a check is five chances\n * for one of them to keep the old question.\n *\n * ── Why the refusal names the domain ────────────────────────────────────────\n * The operator's next step differs by where the name stands. If it stands here they rename; if\n * it is inherited they may decide this domain stands its own. They cannot choose without being\n * told which it is and from where — a refusal that only says \"duplicated\" leaves them guessing\n * at a domain they may not have known existed.\n *\n * ── Why a `Refusal` and not a sentence ──────────────────────────────────────\n * The rest of this file's neighbours throw English strings, which puts English on a Korean\n * screen and makes the screen write the reason itself (ADR-0054). This refusal is new, so it\n * starts as a name and values; the older sentences around it are left alone.\n */\nexport async function refuseRoleNameIfStanding({\n name,\n domain,\n separately,\n tx\n}: {\n name: string\n domain: AskingDomain\n separately?: boolean\n tx?: EntityManager\n}): Promise<void> {\n const repository = getRepository(Role, tx)\n\n /*\n * ⚠ This domain is asked first, on its own, and the parent only if it has nothing.\n *\n * One read over both with `In([own, parent])` has no `ORDER BY`, so when the name stands in\n * **both** the answer is whichever row the driver hands back. If that is the parent's, the\n * refusal is the inherited one — and `separately` is allowed to pass that, which stands a\n * second row of the name in a domain that already has it. Exactly the state the rule exists to\n * prevent, reached through the rule.\n *\n * Standing in both is not a corner: the migration of ADR-0062 decision 2 passes through it for\n * its whole length, because the domain's own role stands up first and the parent is cut last.\n *\n * This is the same defect that `role(name)` had and the same fix. It went back in here because\n * \"one read\" looked like the tidier shape.\n */\n const own = await standingRoleQuery(repository, name, domain.id).getOne()\n\n const found = own || (domain.parentId ? await standingRoleQuery(repository, name, domain.parentId).getOne() : null)\n\n const standing = nameStands({ domainId: found?.domain?.id || found?.domainId }, domain)\n\n if (!refusesName(standing, separately)) {\n return\n }\n\n if (standing === 'here') {\n throw new Refusal(\n 'role-name-taken-here',\n { name },\n `This domain already has a role called \"${name}\".`\n )\n }\n\n throw new Refusal(\n 'role-name-stands-inherited',\n { name, domain: found?.domain?.name || '' },\n `A role called \"${name}\" is already visible here, defined in \"${found?.domain?.name}\". ` +\n `Stand this domain's own of that name only if that is what you mean to do.`\n )\n}\n\n/**\n * The read behind the check: a role of this name **in one named domain**.\n *\n * ── Why this is a builder and not a `findOne` ───────────────────────────────\n * One domain at a time is the whole point, and the harness that would catch it going back to\n * both is sqlite — which returns the rows in an order that happens to be the right answer, so it\n * cannot fail the way a server does. The repository's own note says as much about column\n * dialects, and `domain-inheritance-sentinel.test.ts` says it about this exact class.\n *\n * So the query is something a test can read **before it is sent**. What we build is the same on\n * every driver, which is the only way to reach a defect our sqlite development cannot see.\n */\nexport function standingRoleQuery(\n repository: Repository<Role>,\n name: string,\n domainId: string\n): SelectQueryBuilder<Role> {\n return repository\n .createQueryBuilder('ROLE')\n .leftJoinAndSelect('ROLE.domain', 'ROLE_DOMAIN')\n .where('ROLE.name = :name', { name })\n .andWhere('ROLE_DOMAIN.id = :domainId', { domainId })\n}\n"]}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether a role may be given a name that is already standing. (Pure rule — ADR-0062 decision 3.)
|
|
3
|
+
*
|
|
4
|
+
* ## The situation
|
|
5
|
+
*
|
|
6
|
+
* A domain sees its own roles and its parent's. Until this rule, the duplicate checks asked only
|
|
7
|
+
* about the domain itself, so a domain with a parent could be given a role whose name was already
|
|
8
|
+
* visible through inheritance. Two rows then answered to one name: every list showed both, and
|
|
9
|
+
* resolving the name picked one. An approval line could end up pointing at the one nobody looks
|
|
10
|
+
* at.
|
|
11
|
+
*
|
|
12
|
+
* ## What is refused, and what is not
|
|
13
|
+
*
|
|
14
|
+
* ```
|
|
15
|
+
* here the domain already has this name always refused
|
|
16
|
+
* inherited the parent has it and this domain not refused unless the operator says so
|
|
17
|
+
* nowhere neither stands
|
|
18
|
+
* ```
|
|
19
|
+
*
|
|
20
|
+
* ## Why the escape hatch does not ask for a different name
|
|
21
|
+
*
|
|
22
|
+
* ⚠ This is the part that is easy to get backwards. ADR-0062 decision 2 sets out the only path
|
|
23
|
+
* for moving a domain off an inherited structure: **stand the role up here → move the people to
|
|
24
|
+
* the role of the same name → cut the parent last.** The name being the same is the point of it
|
|
25
|
+
* — the domain's own role is standing up to *replace* what it inherits, and once the parent is
|
|
26
|
+
* cut there is one name again.
|
|
27
|
+
*
|
|
28
|
+
* So requiring a different name would close the one route decision 2 defines. The escape is the
|
|
29
|
+
* operator saying, in as many words, that this domain stands its own — and the name stays.
|
|
30
|
+
* Asking for a different name remains available on top of that, for when somebody genuinely
|
|
31
|
+
* wants a second seat with the same privileges; it is a choice, never a requirement.
|
|
32
|
+
*/
|
|
33
|
+
/** Where a role of this name already stands, seen from the domain that is asking. */
|
|
34
|
+
export type NameStanding = 'nowhere' | 'here' | 'inherited';
|
|
35
|
+
/** The domain asking. `parentId` absent means it inherits from nobody. */
|
|
36
|
+
export type AskingDomain = {
|
|
37
|
+
id: string;
|
|
38
|
+
parentId?: string | null;
|
|
39
|
+
};
|
|
40
|
+
/** A role that was found carrying the name, or nothing. */
|
|
41
|
+
export type StandingRole = {
|
|
42
|
+
domainId?: string | null;
|
|
43
|
+
} | null | undefined;
|
|
44
|
+
/**
|
|
45
|
+
* Where the name stands.
|
|
46
|
+
*
|
|
47
|
+
* A role found in neither this domain nor its parent is `nowhere` — the caller is expected to
|
|
48
|
+
* have looked in those two places, and a row from somewhere else is not this domain's business.
|
|
49
|
+
*/
|
|
50
|
+
export declare function nameStands(found: StandingRole, domain: AskingDomain): NameStanding;
|
|
51
|
+
/**
|
|
52
|
+
* Does the name refuse?
|
|
53
|
+
*
|
|
54
|
+
* `separately` is the operator saying this domain stands its own of that name. It reaches only
|
|
55
|
+
* the inherited case — a name already taken **in this domain** is not a thing anybody can opt
|
|
56
|
+
* into, because the two rows would be indistinguishable in every respect.
|
|
57
|
+
*/
|
|
58
|
+
export declare function refusesName(standing: NameStanding, separately?: boolean): boolean;
|