@coffre/vault 0.10.2 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cloudflare.js +1 -1
- package/dist/node.js +1 -1
- package/dist/{vault-66Xf0Hw1.js → vault-DEjPmLNt.js} +210 -90
- package/package.json +3 -3
- package/src/decide.ts +198 -0
- package/src/store.ts +26 -3
- package/src/vault.ts +56 -112
package/dist/cloudflare.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { a as verifyCheckpoint, i as checkpointMessage, n as KekUnavailableError, o as derivedSigningKey, r as awsKms, s as resolveVaultConfig, t as KekBadClaimError } from "./src-B0d-W-SI.js";
|
|
2
|
-
import { n as prepareVault, t as openVault } from "./vault-
|
|
2
|
+
import { n as prepareVault, t as openVault } from "./vault-DEjPmLNt.js";
|
|
3
3
|
import { createDatabase } from "@coffre/db";
|
|
4
4
|
import { HyperdrivePool, postgres } from "@coffre/db/hyperdrive";
|
|
5
5
|
import { WorkerEntrypoint } from "cloudflare:workers";
|
package/dist/node.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { a as verifyCheckpoint, i as checkpointMessage, n as KekUnavailableError, o as derivedSigningKey, r as awsKms, s as resolveVaultConfig, t as KekBadClaimError } from "./src-B0d-W-SI.js";
|
|
2
|
-
import { n as prepareVault, t as openVault } from "./vault-
|
|
2
|
+
import { n as prepareVault, t as openVault } from "./vault-DEjPmLNt.js";
|
|
3
3
|
import { chmodSync, existsSync, lstatSync, rmSync } from "node:fs";
|
|
4
4
|
import { Agent, createServer, request } from "node:http";
|
|
5
5
|
import { openDatabase } from "@coffre/db/connect";
|
|
@@ -2,7 +2,7 @@ import { ACCESS_ACTIONS, ACCESS_ACTIONS as ACCESS_ACTIONS$1, POLICY_REVISION, ch
|
|
|
2
2
|
import { DEK_BYTES, KEY_CHECK, KEY_CHECK_CONTEXT, KEY_CHECK_VALUE, KekBadClaimError, KekCancelledError, KekUnavailableError, LocalKekProvider } from "@coffre/core/kek";
|
|
3
3
|
import { createHash, createHmac, hkdfSync, randomUUID, timingSafeEqual } from "node:crypto";
|
|
4
4
|
import { prepared, tablesOf } from "@coffre/db";
|
|
5
|
-
import { DEFAULT_POLICY, EVERYWHERE, EVERY_ENVIRONMENT, FIXED_ROLES, INSTANCE_ROLES, NOTHING, PROJECT_ONLY_PERMISSIONS, ROLES, ROLE_ID, UNWRITTEN_FORK_MS, allows, assignableToEnvironment, definitionProblem, givesService, grantKind, isBuiltinRole, isCredentialScope, isInstanceRole, isRole, isScope, managesService, mayManageAccess, normalDefinition, normalScope, ownsInstance, runsInstance, setsUpServices, unscoped, withinCredential } from "@coffre/core/access";
|
|
5
|
+
import { DEFAULT_POLICY, EVERYWHERE, EVERY_ENVIRONMENT, FIXED_ROLES, INSTANCE_ROLES, NOTHING, PROJECT_ONLY_PERMISSIONS, ROLES, ROLE_ID, UNWRITTEN_FORK_MS, allows, assignableToEnvironment, definitionProblem, givesService, grantKind, isBuiltinRole, isCredentialScope, isInstanceRole, isRole, isScope, managesService, mayManageAccess, normalDefinition, normalScope, ownsInstance, ownsProject, readsSensitive, runsInstance, setsUpServices, unscoped, useOnly, withinCredential } from "@coffre/core/access";
|
|
6
6
|
import { GENESIS_HASH, deriveLogKey, verifyEntries } from "@coffre/core/audit";
|
|
7
7
|
import { checkContext, ownerKey } from "@coffre/core/envelope";
|
|
8
8
|
import { TOMBSTONE, shownMember } from "@coffre/core/schemas";
|
|
@@ -43,6 +43,138 @@ async function signer(seed) {
|
|
|
43
43
|
};
|
|
44
44
|
}
|
|
45
45
|
//#endregion
|
|
46
|
+
//#region src/decide.ts
|
|
47
|
+
/**
|
|
48
|
+
* Where a decision checks a permission: an environment, with its slug as
|
|
49
|
+
* the store has it when it was read and is in the project claimed;
|
|
50
|
+
* otherwise none, which no grant on a slug matches.
|
|
51
|
+
*/
|
|
52
|
+
function placeIn(place, environments) {
|
|
53
|
+
const environment = environments.get(place.environmentId);
|
|
54
|
+
const slug = environment?.projectId === place.projectId ? environment.slug : null;
|
|
55
|
+
return {
|
|
56
|
+
projectId: place.projectId,
|
|
57
|
+
environmentId: place.environmentId,
|
|
58
|
+
environmentSlug: slug
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* A key of a personal config is its owner's alone (docs/design/forking.md,
|
|
63
|
+
* section 4): no grant or role opens or wraps it, an Owner's included,
|
|
64
|
+
* only its owner, while they read the environment it is a config of,
|
|
65
|
+
* whatever the call is. Nor does any reference reach into or out of one.
|
|
66
|
+
* Undefined for a key of any other environment, decided as ever.
|
|
67
|
+
*/
|
|
68
|
+
function personalRefusal(reader, principal, permission, secret, at, environments) {
|
|
69
|
+
const own = environments.get(secret.environmentId);
|
|
70
|
+
const elsewhere = at.environmentId !== secret.environmentId;
|
|
71
|
+
const held = elsewhere ? environments.get(at.environmentId) : own;
|
|
72
|
+
if (own?.owner == null && held?.owner == null) return void 0;
|
|
73
|
+
if (elsewhere || own === void 0 || principal !== own.owner || own.parentId === null) return "personal";
|
|
74
|
+
const parent = placeIn({
|
|
75
|
+
projectId: secret.projectId,
|
|
76
|
+
environmentId: own.parentId
|
|
77
|
+
}, environments);
|
|
78
|
+
const gone = own.deleted || (environments.get(own.parentId)?.deleted ?? true);
|
|
79
|
+
const refused = refuses(reader, "secret.use", parent, gone) === null ? null : refuses(reader, "secret.read", parent, gone);
|
|
80
|
+
if (refused !== null) return refused;
|
|
81
|
+
return withinCredential(reader.live, permission === "secret.read" ? "secret.use" : permission, parent) ? null : "no_grant";
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Why `reader` may not do `permission` at `where`, or null if they may. A
|
|
85
|
+
* deleted place is refused before any grant is asked, so no kind of grant,
|
|
86
|
+
* on the project, the environment or every project, can reach into one.
|
|
87
|
+
*/
|
|
88
|
+
function refuses(reader, permission, where, deleted) {
|
|
89
|
+
if (reader.status === "tampered") return "tampered";
|
|
90
|
+
if (reader.status === "removed") return "removed";
|
|
91
|
+
if (reader.status === "unknown") return "not_a_member";
|
|
92
|
+
if (deleted) return "deleted";
|
|
93
|
+
if (allows(reader.live, permission, where)) return null;
|
|
94
|
+
return allows(reader.all, permission, where) ? "expired" : "no_grant";
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* What refuses each key of a call by where it is, before any `vet`: its
|
|
98
|
+
* personal config's owner, any one of the permissions it `needs` besides,
|
|
99
|
+
* `permission` itself, at `at`, its reference's holder or where it is; and
|
|
100
|
+
* a key it `owns` that its reader does not own the project of.
|
|
101
|
+
*/
|
|
102
|
+
function keyRefusals(reader, call) {
|
|
103
|
+
const { principal, permission, secrets, at, environments, needs } = call;
|
|
104
|
+
const gone = (place) => environments.get(place.environmentId)?.deleted ?? false;
|
|
105
|
+
const refusalOf = (secret, i) => {
|
|
106
|
+
const personal = personalRefusal(reader, principal, permission, secret, at[i], environments);
|
|
107
|
+
if (personal !== void 0) return personal;
|
|
108
|
+
const also = needs[i];
|
|
109
|
+
if (also != null) {
|
|
110
|
+
const place = placeIn(at[i], environments);
|
|
111
|
+
const refused = also.map((need) => refuses(reader, need, place, gone(secret) || gone(at[i])));
|
|
112
|
+
if (refused.every((code) => code !== null)) return refused[0] ?? "no_grant";
|
|
113
|
+
}
|
|
114
|
+
return refuses(reader, permission, placeIn(at[i], environments), gone(secret) || gone(at[i]));
|
|
115
|
+
};
|
|
116
|
+
return secrets.map((secret, i) => {
|
|
117
|
+
const code = refusalOf(secret, i);
|
|
118
|
+
return code === null && call.owns?.[i] === true && !ownsProject(reader.live, secret.projectId) ? "not_allowed" : code;
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Each key's refusal once its sensitivity is weighed too, after `vet`. A
|
|
123
|
+
* sensitive value is only ever used for a person, never handed back, by
|
|
124
|
+
* whatever credential of theirs asks; and handed back to a service account
|
|
125
|
+
* only on a credential an Owner of its own project issued, a reference's
|
|
126
|
+
* source's too, whose value it is (`readsSensitive`, R32). And a fork
|
|
127
|
+
* copies one, for anyone, a service account too, only where its app says
|
|
128
|
+
* it keeps it sensitive (an app of 0.9, mid-deploy, would write the copy as
|
|
129
|
+
* a secret its people read), and never one read through a reference.
|
|
130
|
+
*/
|
|
131
|
+
function sensitiveRefusals(codes, reader, call) {
|
|
132
|
+
const copying = call.action === "secret.read" && call.purpose === "copy";
|
|
133
|
+
const handedBack = call.action === "secret.read" && handsBack({
|
|
134
|
+
purpose: call.purpose ?? "",
|
|
135
|
+
keepsSensitive: call.keepsSensitive === true
|
|
136
|
+
});
|
|
137
|
+
const person = call.principal.startsWith("user:");
|
|
138
|
+
return call.secrets.map((secret, i) => {
|
|
139
|
+
const code = codes[i] ?? null;
|
|
140
|
+
if (code !== null || secret.sensitive !== true) return code;
|
|
141
|
+
if (copying && call.keepsSensitive !== true || copying && call.viaReference?.[i] === true || handedBack && person) return "sensitive";
|
|
142
|
+
if (handedBack && !readsSensitive(reader.live, secret.projectId)) return "not_owner_issued";
|
|
143
|
+
return null;
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Each key's refusal once a workload's listing is weighed too, after `vet`
|
|
148
|
+
* and before its sensitivity (docs/design/workload-access.md, section 6.1).
|
|
149
|
+
* A workload is handed back, for any purpose but the proxy's use, only a
|
|
150
|
+
* value its credential lists where the value is held (`useOnly`): each
|
|
151
|
+
* secret is that place, a reference's source, a fork's parent, whatever
|
|
152
|
+
* place the call checked its permission at. A copy too, kept sensitive or
|
|
153
|
+
* not: it would land where the workload may read it. The vault opens no
|
|
154
|
+
* plain value, so every key here is a secret or a sensitive one.
|
|
155
|
+
*/
|
|
156
|
+
function useOnlyRefusals(codes, reader, call) {
|
|
157
|
+
const handedBack = call.action === "secret.read" && call.purpose !== "use";
|
|
158
|
+
return call.secrets.map((secret, i) => codes[i] ?? (handedBack && useOnly(reader.live, secret) ? "use_only" : null));
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* Why `reader` may not make a reference at `holder` to `source`, or null, past
|
|
162
|
+
* what only the database settles (a source of another claim, a reference to
|
|
163
|
+
* itself). A personal config's values are bound to its owner: no reference
|
|
164
|
+
* holds one, nor reads one. Making one hands its source's value to whoever
|
|
165
|
+
* reads the holder, whatever its nature becomes: its maker writes the holder,
|
|
166
|
+
* reads the source there, as any direct access, and, for a sensitive one,
|
|
167
|
+
* which nobody reads, uses it there too (R24). A workload refers only to a
|
|
168
|
+
* value it is handed back, a plain one or one it lists (`useOnly`): it
|
|
169
|
+
* would otherwise move a value it uses to a place whose keys it reads.
|
|
170
|
+
*/
|
|
171
|
+
function referenceRefusal(reader, holder, source, environments, nature) {
|
|
172
|
+
if (environments.get(holder.environmentId)?.owner != null || environments.get(source.environmentId)?.owner != null) return "personal";
|
|
173
|
+
const gone = (place) => environments.get(place.environmentId)?.deleted ?? false;
|
|
174
|
+
const at = placeIn(source, environments);
|
|
175
|
+
return refuses(reader, "secret.write", placeIn(holder, environments), gone(holder)) ?? refuses(reader, "secret.read", at, gone(source)) ?? (nature === "sensitive" ? refuses(reader, "secret.use", at, gone(source)) : null) ?? (nature !== "plain" && useOnly(reader.live, source) ? "use_only" : null);
|
|
176
|
+
}
|
|
177
|
+
//#endregion
|
|
46
178
|
//#region src/store.ts
|
|
47
179
|
/**
|
|
48
180
|
* On Postgres, fail a lock wait in this transaction after `ms`, rather than
|
|
@@ -345,8 +477,10 @@ async function natures(db, secretIds) {
|
|
|
345
477
|
}
|
|
346
478
|
/**
|
|
347
479
|
* For each version a rewrap would bring back into its secret, its nature
|
|
348
|
-
*
|
|
349
|
-
*
|
|
480
|
+
* when it is an old value, no looser than it was stored nor than its value
|
|
481
|
+
* was made since (the newest version of its very ciphertext), not the
|
|
482
|
+
* secret's current one; null for the current one, which a mark wraps again
|
|
483
|
+
* with no value changing.
|
|
350
484
|
*/
|
|
351
485
|
async function restoredNatures(db, items) {
|
|
352
486
|
if (items.length === 0) return [];
|
|
@@ -356,8 +490,8 @@ async function restoredNatures(db, items) {
|
|
|
356
490
|
version: secrets.currentVersionId
|
|
357
491
|
}).from(secrets).where(inArray(secrets.id, [...new Set(items.map((item) => item.secretId))])), db.select({
|
|
358
492
|
id: secretVersions.id,
|
|
359
|
-
sensitive: secretVersions.sensitive,
|
|
360
|
-
plain:
|
|
493
|
+
sensitive: sql`(${secretVersions.sensitive} OR (${heldAs(secretVersions, sql`same.sensitive`)}))`,
|
|
494
|
+
plain: sql`(${secretVersions.plaintext} IS NOT NULL AND (${heldAs(secretVersions, sql`same.plaintext IS NOT NULL`)}))`
|
|
361
495
|
}).from(secretVersions).where(inArray(secretVersions.id, [...new Set(items.map((item) => item.from))]))]);
|
|
362
496
|
const currentOf = new Map(current.map((row) => [row.id, row.version]));
|
|
363
497
|
const natureOf = new Map(versions.map((row) => [row.id, Boolean(row.sensitive) ? "sensitive" : Boolean(row.plain) ? "plain" : "secret"]));
|
|
@@ -467,7 +601,8 @@ async function versions(db, ids) {
|
|
|
467
601
|
currentVersionId: secrets.currentVersionId,
|
|
468
602
|
sensitive: secretVersions.sensitive,
|
|
469
603
|
newest: sql`(SELECT max(newest.version) FROM secret_versions newest WHERE newest.secret_id = ${secretVersions.secretId})`,
|
|
470
|
-
newestSensitive: sql`(SELECT newest.sensitive FROM secret_versions newest WHERE newest.secret_id = ${secretVersions.secretId} ORDER BY newest.version DESC LIMIT 1)
|
|
604
|
+
newestSensitive: sql`(SELECT newest.sensitive FROM secret_versions newest WHERE newest.secret_id = ${secretVersions.secretId} ORDER BY newest.version DESC LIMIT 1)`,
|
|
605
|
+
heldSensitive: sql`(${heldAs(secretVersions, sql`same.sensitive`)})`
|
|
471
606
|
}).from(secretVersions).innerJoin(secrets, eq(secrets.id, secretVersions.secretId)).innerJoin(environments, eq(environments.id, secrets.environmentId)).innerJoin(projects, eq(projects.id, secrets.projectId)).where(and(inArray(secretVersions.id, [...new Set(ids)]), or(sealed$1(secretVersions.wrappedDek), tombstone(environments.slug), tombstone(projects.slug))))).map((row) => ({
|
|
472
607
|
id: row.id,
|
|
473
608
|
deleted: row.deleted,
|
|
@@ -486,9 +621,18 @@ async function versions(db, ids) {
|
|
|
486
621
|
bytes: row.wrappedDek.toString("base64")
|
|
487
622
|
},
|
|
488
623
|
current: row.currentVersionId === row.id && Number(row.newest) === row.version,
|
|
489
|
-
newestSensitive: Boolean(row.newestSensitive)
|
|
624
|
+
newestSensitive: Boolean(row.newestSensitive),
|
|
625
|
+
heldSensitive: Boolean(row.heldSensitive)
|
|
490
626
|
}));
|
|
491
627
|
}
|
|
628
|
+
/**
|
|
629
|
+
* `column` of the newest version of a version's secret with its very ciphertext: its value, as it is held now. Known
|
|
630
|
+
* by its IV and tag, which under one data key name one ciphertext, and which, unlike the ciphertext, no erasure empties.
|
|
631
|
+
*/
|
|
632
|
+
function heldAs(secretVersions, column) {
|
|
633
|
+
return sql`SELECT ${column} FROM secret_versions same WHERE same.secret_id = ${secretVersions.secretId}
|
|
634
|
+
AND same.iv = ${secretVersions.iv} AND same.auth_tag = ${secretVersions.authTag} ORDER BY same.version DESC LIMIT 1`;
|
|
635
|
+
}
|
|
492
636
|
//#endregion
|
|
493
637
|
//#region src/log.ts
|
|
494
638
|
/**
|
|
@@ -1723,7 +1867,7 @@ var Outage = class {
|
|
|
1723
1867
|
}
|
|
1724
1868
|
};
|
|
1725
1869
|
/** A rewrap that would make a value less sensitive than it is. */
|
|
1726
|
-
const LOOSER = "a value is made less sensitive only by writing it again: an old version comes back, and a key is made stricter, as sensitive as its newest version or more";
|
|
1870
|
+
const LOOSER = "a value is made less sensitive only by writing it again: an old version comes back, and a key is made stricter, as sensitive as its newest version or more; only its current sensitive value is made a secret in place, by an Owner of its project";
|
|
1727
1871
|
function refusal(code, message) {
|
|
1728
1872
|
return {
|
|
1729
1873
|
code,
|
|
@@ -1741,6 +1885,8 @@ const MESSAGES = {
|
|
|
1741
1885
|
ended: "the reference read through was ended",
|
|
1742
1886
|
personal: "a personal config's values are its owner's alone",
|
|
1743
1887
|
sensitive: "a sensitive value is never shown to anyone, Owners included: `coffre run` gives your app a placeholder for it through the credential proxy, and a service account reads it",
|
|
1888
|
+
not_owner_issued: "a service account reads a sensitive value only on a token or trust binding an Owner of its project issued",
|
|
1889
|
+
use_only: "a workload uses this value through coffre and never holds it: list it in its binding's reads, where the value is held, if it must",
|
|
1744
1890
|
not_allowed: "not allowed to change this",
|
|
1745
1891
|
last_owner: "the last Owner of the whole instance stays one: make someone else an Owner first",
|
|
1746
1892
|
invalid: "not something the rules allow",
|
|
@@ -1751,21 +1897,30 @@ const MESSAGES = {
|
|
|
1751
1897
|
};
|
|
1752
1898
|
/**
|
|
1753
1899
|
* What the one asking holds through the credential their request came in
|
|
1754
|
-
* on: no more than its scope (`allows`)
|
|
1755
|
-
*
|
|
1900
|
+
* on: no more than its scope (`allows`), and, a workload's, handed back
|
|
1901
|
+
* only what the scope lists (`useOnly`). A scope that is not one, a kind of
|
|
1902
|
+
* caller its principal cannot be, or a workload with no scope to list what
|
|
1903
|
+
* it reads, is a bug of the app's, which fails the call. None said is an
|
|
1904
|
+
* app older than 0.11, which has no workloads (`Through`).
|
|
1756
1905
|
*/
|
|
1757
|
-
function through(standing, { credentialScope }) {
|
|
1758
|
-
|
|
1759
|
-
if (!
|
|
1906
|
+
function through(standing, { credentialScope, caller }) {
|
|
1907
|
+
const { principal } = standing;
|
|
1908
|
+
if (caller !== void 0 && !(caller === "person" ? principal.startsWith("user:") : (caller === "bearer" || caller === "workload") && principal.startsWith("token:"))) throw new Error(`caller must be person for a user: principal, bearer or workload for a token: one, got: ${String(caller)}`);
|
|
1909
|
+
if (caller === "workload" && credentialScope == null) throw new Error("a workload caller comes with its credential scope, which lists what it reads");
|
|
1910
|
+
if (credentialScope != null && !isCredentialScope(credentialScope)) throw new Error("credentialScope must be a list of grants, one a place");
|
|
1911
|
+
const held = {
|
|
1912
|
+
...credentialScope == null ? {} : { credentialScope },
|
|
1913
|
+
...caller === void 0 ? {} : { kind: caller }
|
|
1914
|
+
};
|
|
1760
1915
|
return {
|
|
1761
1916
|
...standing,
|
|
1762
1917
|
live: {
|
|
1763
1918
|
...standing.live,
|
|
1764
|
-
|
|
1919
|
+
...held
|
|
1765
1920
|
},
|
|
1766
1921
|
all: {
|
|
1767
1922
|
...standing.all,
|
|
1768
|
-
|
|
1923
|
+
...held
|
|
1769
1924
|
}
|
|
1770
1925
|
};
|
|
1771
1926
|
}
|
|
@@ -2286,6 +2441,7 @@ var VaultService = class {
|
|
|
2286
2441
|
permission: input.purpose === "use" ? "secret.use" : "secret.read",
|
|
2287
2442
|
secrets: items.map((item) => item.secret),
|
|
2288
2443
|
places: sealed.map((seal, i) => seal?.holder ?? items[i].secret),
|
|
2444
|
+
viaReference: sealed.map((seal) => seal !== null),
|
|
2289
2445
|
references: ends,
|
|
2290
2446
|
vet: ends.length === 0 ? void 0 : async (_, reads) => sealed.map((seal) => seal !== null && reads.ended.has(seal.seq) ? "ended" : null),
|
|
2291
2447
|
remote,
|
|
@@ -2349,13 +2505,19 @@ var VaultService = class {
|
|
|
2349
2505
|
...keyEntry("key.rewrap", principal, source.secret, "deny", "bad_claim", input),
|
|
2350
2506
|
secretVersionId: source.id
|
|
2351
2507
|
})));
|
|
2352
|
-
|
|
2508
|
+
const loosens = items.map((item, i) => item.secret.sensitive !== true && loaded.versions[i].secret.sensitive === true && loaded.versions[i].current);
|
|
2509
|
+
const strict = (i) => loaded.versions[i].secret.sensitive || loaded.versions[i].heldSensitive || loaded.versions[i].newestSensitive;
|
|
2510
|
+
if (items.some((item, i) => !loosens[i] && item.secret.sensitive !== true && strict(i))) return this.#refuseVersions(principal, "invalid", loaded.versions.map((source) => ({
|
|
2353
2511
|
...keyEntry("key.rewrap", principal, source.secret, "deny", "invalid", input),
|
|
2354
2512
|
secretVersionId: source.id
|
|
2355
2513
|
})), LOOSER);
|
|
2356
2514
|
const sources = new Map(items.map((item, i) => [item.secret, loaded.versions[i]]));
|
|
2515
|
+
const loosened = new Set(items.flatMap((item, i) => loosens[i] ? [item.secret] : []));
|
|
2357
2516
|
const entry = (secret, decision, code) => ({
|
|
2358
|
-
...keyEntry("key.rewrap", principal, secret, decision, code, input, {
|
|
2517
|
+
...keyEntry("key.rewrap", principal, secret, decision, code, input, {
|
|
2518
|
+
from: sources.get(secret).secret.version,
|
|
2519
|
+
...loosened.has(secret) ? { was: "sensitive" } : {}
|
|
2520
|
+
}),
|
|
2359
2521
|
secretVersionId: sources.get(secret).id
|
|
2360
2522
|
});
|
|
2361
2523
|
const remote = this.#remote(this.#config.keks.primary) || items.some(({ wrapped }) => this.#remote(this.#config.keks.providerOf(wrapped)));
|
|
@@ -2369,8 +2531,14 @@ var VaultService = class {
|
|
|
2369
2531
|
input,
|
|
2370
2532
|
vet: async (db) => {
|
|
2371
2533
|
const newest = await newestSensitive(db, items.map(({ secret }) => secret.secretId));
|
|
2372
|
-
|
|
2534
|
+
const again = new Map((await versions(db, items.map(({ secret }) => sources.get(secret).id))).map((version) => [version.id, version]));
|
|
2535
|
+
return items.map(({ secret }, i) => {
|
|
2536
|
+
const now = again.get(sources.get(secret).id);
|
|
2537
|
+
if (loosens[i]) return now?.current === true ? null : "invalid";
|
|
2538
|
+
return secret.sensitive !== true && (newest.has(secret.secretId) || now?.heldSensitive === true) ? "invalid" : null;
|
|
2539
|
+
});
|
|
2373
2540
|
},
|
|
2541
|
+
owns: loosens,
|
|
2374
2542
|
needs: async (db) => {
|
|
2375
2543
|
return (await restoredNatures(db, items.map(({ secret }) => ({
|
|
2376
2544
|
secretId: secret.secretId,
|
|
@@ -2471,28 +2639,31 @@ var VaultService = class {
|
|
|
2471
2639
|
const reader = through(await this.#standing(d.tx, principal, d.members.get(principal), d.at, d.reports, d.reads), call.input);
|
|
2472
2640
|
const at = secrets.map((secret, i) => call.places?.[i] ?? secret);
|
|
2473
2641
|
const environments = d.reads.environments;
|
|
2474
|
-
const gone = (place) => environments.get(place.environmentId)?.deleted ?? false;
|
|
2475
2642
|
owners.clear();
|
|
2476
2643
|
for (const [id, environment] of environments) if (environment.owner !== null) owners.set(id, ownerKey(environment.owner));
|
|
2477
2644
|
const needs = call.needs === void 0 ? [] : await call.needs(d.tx);
|
|
2478
|
-
|
|
2479
|
-
|
|
2480
|
-
|
|
2481
|
-
|
|
2482
|
-
|
|
2483
|
-
|
|
2484
|
-
|
|
2485
|
-
|
|
2486
|
-
}
|
|
2487
|
-
return refuses(reader, call.permission, placeIn(at[i], environments), gone(secret) || gone(at[i]));
|
|
2645
|
+
let codes = keyRefusals(reader, {
|
|
2646
|
+
principal,
|
|
2647
|
+
permission: call.permission,
|
|
2648
|
+
secrets,
|
|
2649
|
+
at,
|
|
2650
|
+
environments,
|
|
2651
|
+
needs,
|
|
2652
|
+
owns: call.owns
|
|
2488
2653
|
});
|
|
2489
|
-
if (call.vet !== void 0 && !codes.some((code) => code !== null))
|
|
2490
|
-
|
|
2491
|
-
|
|
2492
|
-
purpose: call.input.purpose
|
|
2493
|
-
|
|
2494
|
-
})
|
|
2495
|
-
|
|
2654
|
+
if (call.vet !== void 0 && !codes.some((code) => code !== null)) codes = await call.vet(d.tx, d.reads);
|
|
2655
|
+
codes = useOnlyRefusals(codes, reader, {
|
|
2656
|
+
action,
|
|
2657
|
+
purpose: call.input.purpose,
|
|
2658
|
+
secrets
|
|
2659
|
+
});
|
|
2660
|
+
codes = sensitiveRefusals(codes, reader, {
|
|
2661
|
+
action,
|
|
2662
|
+
principal,
|
|
2663
|
+
purpose: call.input.purpose,
|
|
2664
|
+
keepsSensitive: call.input.keepsSensitive,
|
|
2665
|
+
secrets,
|
|
2666
|
+
viaReference: call.viaReference
|
|
2496
2667
|
});
|
|
2497
2668
|
let first = codes.find((code) => code !== null) ?? null;
|
|
2498
2669
|
if (first === null && action === "secret.read" && this.#overBulkLimit(d)) first = "bulk_limit";
|
|
@@ -2656,14 +2827,11 @@ var VaultService = class {
|
|
|
2656
2827
|
const source = sources.get(item.source.secretId);
|
|
2657
2828
|
return source === void 0 ? [item.holder.environmentId] : [item.holder.environmentId, source.environmentId];
|
|
2658
2829
|
}));
|
|
2659
|
-
const gone = (place) => environments.get(place.environmentId)?.deleted ?? false;
|
|
2660
2830
|
const codes = items.map((item) => {
|
|
2661
2831
|
const source = sources.get(item.source.secretId);
|
|
2662
2832
|
if (source === void 0 || projectOf.get(item.holder.environmentId) !== item.holder.projectId) return "bad_claim";
|
|
2663
2833
|
if (source.secretId === item.holder.secretId) return "invalid";
|
|
2664
|
-
|
|
2665
|
-
const at = placeIn(source, environments);
|
|
2666
|
-
return refuses(reader, "secret.write", placeIn(item.holder, environments), gone(item.holder)) ?? refuses(reader, "secret.read", at, gone(source)) ?? (natures$1.get(source.secretId) === "sensitive" ? refuses(reader, "secret.use", at, gone(source)) : null);
|
|
2834
|
+
return referenceRefusal(reader, item.holder, source, environments, natures$1.get(source.secretId) ?? "secret");
|
|
2667
2835
|
});
|
|
2668
2836
|
const first = codes.find((code) => code !== null) ?? null;
|
|
2669
2837
|
if (first !== null) throw new Refused(refusal(first, MESSAGES[first]), items.map((item, i) => entry(item, "deny", codes[i] ?? first)));
|
|
@@ -3499,8 +3667,8 @@ var VaultService = class {
|
|
|
3499
3667
|
at: d.at
|
|
3500
3668
|
}))].sort((a, b) => a.environmentId < b.environmentId ? -1 : 1);
|
|
3501
3669
|
} else {
|
|
3502
|
-
if (!
|
|
3503
|
-
next.protected = next.protected.filter((place) => !change.environmentIds.includes(place.environmentId));
|
|
3670
|
+
if (!ownsProject(acting.live, change.projectId)) throw refused("not_allowed", "only an Owner of its project, or of the whole instance, unprotects an environment");
|
|
3671
|
+
next.protected = next.protected.filter((place) => place.projectId !== change.projectId || !change.environmentIds.includes(place.environmentId));
|
|
3504
3672
|
}
|
|
3505
3673
|
} else throw refused("invalid", "a change is to a role, an instance role, or protection");
|
|
3506
3674
|
const answer = {
|
|
@@ -4505,54 +4673,6 @@ function viaDetail(via, seal) {
|
|
|
4505
4673
|
};
|
|
4506
4674
|
}
|
|
4507
4675
|
/**
|
|
4508
|
-
* Where a decision checks a permission: an environment, with its slug as
|
|
4509
|
-
* the store has it when it was read and is in the project claimed;
|
|
4510
|
-
* otherwise none, which no grant on a slug matches.
|
|
4511
|
-
*/
|
|
4512
|
-
function placeIn(place, environments) {
|
|
4513
|
-
const environment = environments.get(place.environmentId);
|
|
4514
|
-
const slug = environment?.projectId === place.projectId ? environment.slug : null;
|
|
4515
|
-
return {
|
|
4516
|
-
projectId: place.projectId,
|
|
4517
|
-
environmentId: place.environmentId,
|
|
4518
|
-
environmentSlug: slug
|
|
4519
|
-
};
|
|
4520
|
-
}
|
|
4521
|
-
/**
|
|
4522
|
-
* Why `reader` may not do `permission` at `where`, or null if they may. A
|
|
4523
|
-
* deleted place is refused before any grant is asked, so no kind of grant,
|
|
4524
|
-
* on the project, the environment or every project, can reach into one.
|
|
4525
|
-
*/
|
|
4526
|
-
/**
|
|
4527
|
-
* A key of a personal config is its owner's alone (docs/design/forking.md,
|
|
4528
|
-
* section 4): no grant or role opens or wraps it, an Owner's included,
|
|
4529
|
-
* only its owner, while they read the environment it is a config of,
|
|
4530
|
-
* whatever the call is. Nor does any reference reach into or out of one.
|
|
4531
|
-
* Undefined for a key of any other environment, decided as ever.
|
|
4532
|
-
*/
|
|
4533
|
-
function personalRefusal(reader, principal, permission, secret, at, environments) {
|
|
4534
|
-
const own = environments.get(secret.environmentId);
|
|
4535
|
-
const held = at.environmentId === secret.environmentId ? own : environments.get(at.environmentId);
|
|
4536
|
-
if (own?.owner == null && held?.owner == null) return void 0;
|
|
4537
|
-
if (own !== held || principal !== own?.owner || own.parentId === null) return "personal";
|
|
4538
|
-
const parent = placeIn({
|
|
4539
|
-
projectId: secret.projectId,
|
|
4540
|
-
environmentId: own.parentId
|
|
4541
|
-
}, environments);
|
|
4542
|
-
const gone = own.deleted || (environments.get(own.parentId)?.deleted ?? true);
|
|
4543
|
-
const refused = refuses(reader, "secret.use", parent, gone) === null ? null : refuses(reader, "secret.read", parent, gone);
|
|
4544
|
-
if (refused !== null) return refused;
|
|
4545
|
-
return withinCredential(reader.live, permission === "secret.read" ? "secret.use" : permission, parent) ? null : "no_grant";
|
|
4546
|
-
}
|
|
4547
|
-
function refuses(reader, permission, where, deleted) {
|
|
4548
|
-
if (reader.status === "tampered") return "tampered";
|
|
4549
|
-
if (reader.status === "removed") return "removed";
|
|
4550
|
-
if (reader.status === "unknown") return "not_a_member";
|
|
4551
|
-
if (deleted) return "deleted";
|
|
4552
|
-
if (allows(reader.live, permission, where)) return null;
|
|
4553
|
-
return allows(reader.all, permission, where) ? "expired" : "no_grant";
|
|
4554
|
-
}
|
|
4555
|
-
/**
|
|
4556
4676
|
* An entry about a key. A new secret's row is not committed when its key is
|
|
4557
4677
|
* wrapped, so a wrap names the secret in its payload; the others name it.
|
|
4558
4678
|
*/
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@coffre/vault",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.0",
|
|
4
4
|
"description": "coffre's vault: keys, grants and members, in the database the app uses, as a Cloudflare Worker or on Node.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -36,8 +36,8 @@
|
|
|
36
36
|
},
|
|
37
37
|
"dependencies": {
|
|
38
38
|
"drizzle-orm": "0.45.2",
|
|
39
|
-
"@coffre/core": "0.
|
|
40
|
-
"@coffre/db": "0.
|
|
39
|
+
"@coffre/core": "0.11.0",
|
|
40
|
+
"@coffre/db": "0.11.0"
|
|
41
41
|
},
|
|
42
42
|
"devDependencies": {
|
|
43
43
|
"@types/node": "26.1.1",
|
package/src/decide.ts
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { allows, ownsProject, readsSensitive, useOnly, withinCredential, type HeldKey, type Holdings, type Permission, type Place } from '@coffre/core/access';
|
|
2
|
+
import { handsBack, type Access, type RefusalCode } from '@coffre/core/vault';
|
|
3
|
+
import type { EnvironmentRow } from './store.ts';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* How the vault decides each key of one call, and each reference one makes,
|
|
7
|
+
* as functions of what the decision read and nothing else: the rest of
|
|
8
|
+
* `#keys` and `reference` reads, locks and logs around them. Pure, so that `formal/lean` holds them to its model
|
|
9
|
+
* case by case (`pnpm formal:lean`, docs/formal.md).
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** What a decision reads of someone: whether they are a member, what they hold now, and what they would with their lapsed grants. */
|
|
13
|
+
export type Reader = { status: Access['status']; live: Holdings; all: Holdings };
|
|
14
|
+
|
|
15
|
+
/** An environment a secret is in, or that holds a reference to one. */
|
|
16
|
+
export type SecretPlace = { projectId: string; environmentId: string };
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Where a decision checks a permission: an environment, with its slug as
|
|
20
|
+
* the store has it when it was read and is in the project claimed;
|
|
21
|
+
* otherwise none, which no grant on a slug matches.
|
|
22
|
+
*/
|
|
23
|
+
export function placeIn(place: SecretPlace, environments: ReadonlyMap<string, { projectId: string; slug: string }>): Place {
|
|
24
|
+
const environment = environments.get(place.environmentId);
|
|
25
|
+
const slug = environment?.projectId === place.projectId ? environment.slug : null;
|
|
26
|
+
return { projectId: place.projectId, environmentId: place.environmentId, environmentSlug: slug };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* A key of a personal config is its owner's alone (docs/design/forking.md,
|
|
31
|
+
* section 4): no grant or role opens or wraps it, an Owner's included,
|
|
32
|
+
* only its owner, while they read the environment it is a config of,
|
|
33
|
+
* whatever the call is. Nor does any reference reach into or out of one.
|
|
34
|
+
* Undefined for a key of any other environment, decided as ever.
|
|
35
|
+
*/
|
|
36
|
+
export function personalRefusal(
|
|
37
|
+
reader: Reader,
|
|
38
|
+
principal: string,
|
|
39
|
+
permission: Permission,
|
|
40
|
+
secret: SecretPlace,
|
|
41
|
+
at: SecretPlace,
|
|
42
|
+
environments: ReadonlyMap<string, EnvironmentRow>,
|
|
43
|
+
): RefusalCode | null | undefined {
|
|
44
|
+
const own = environments.get(secret.environmentId);
|
|
45
|
+
const elsewhere = at.environmentId !== secret.environmentId;
|
|
46
|
+
const held = elsewhere ? environments.get(at.environmentId) : own;
|
|
47
|
+
if (own?.owner == null && held?.owner == null) return undefined;
|
|
48
|
+
// Held elsewhere, a reference into or out of one; or not its owner's.
|
|
49
|
+
if (elsewhere || own === undefined || principal !== own.owner || own.parentId === null) return 'personal';
|
|
50
|
+
const parent = placeIn({ projectId: secret.projectId, environmentId: own.parentId }, environments);
|
|
51
|
+
// Its owner reads what they wrote while they read or use the environment it configures (R8).
|
|
52
|
+
const gone = own.deleted || (environments.get(own.parentId)?.deleted ?? true);
|
|
53
|
+
const refused = refuses(reader, 'secret.use', parent, gone) === null ? null : refuses(reader, 'secret.read', parent, gone);
|
|
54
|
+
if (refused !== null) return refused;
|
|
55
|
+
// The credential's scope caps the owner still: their viewer token on the parent reads, and wraps no key. Reading their
|
|
56
|
+
// own values takes use there: a Contributor's handle reads what its owner wrote, and nothing of the parent's.
|
|
57
|
+
return withinCredential(reader.live, permission === 'secret.read' ? 'secret.use' : permission, parent) ? null : 'no_grant';
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Why `reader` may not do `permission` at `where`, or null if they may. A
|
|
62
|
+
* deleted place is refused before any grant is asked, so no kind of grant,
|
|
63
|
+
* on the project, the environment or every project, can reach into one.
|
|
64
|
+
*/
|
|
65
|
+
export function refuses(reader: Reader, permission: Permission, where: Place, deleted: boolean): RefusalCode | null {
|
|
66
|
+
if (reader.status === 'tampered') return 'tampered';
|
|
67
|
+
if (reader.status === 'removed') return 'removed';
|
|
68
|
+
if (reader.status === 'unknown') return 'not_a_member';
|
|
69
|
+
if (deleted) return 'deleted';
|
|
70
|
+
if (allows(reader.live, permission, where)) return null;
|
|
71
|
+
// Would a grant that has lapsed have covered it?
|
|
72
|
+
return allows(reader.all, permission, where) ? 'expired' : 'no_grant';
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* What refuses each key of a call by where it is, before any `vet`: its
|
|
77
|
+
* personal config's owner, any one of the permissions it `needs` besides,
|
|
78
|
+
* `permission` itself, at `at`, its reference's holder or where it is; and
|
|
79
|
+
* a key it `owns` that its reader does not own the project of.
|
|
80
|
+
*/
|
|
81
|
+
export function keyRefusals(
|
|
82
|
+
reader: Reader,
|
|
83
|
+
call: {
|
|
84
|
+
principal: string;
|
|
85
|
+
permission: Permission;
|
|
86
|
+
secrets: readonly SecretPlace[];
|
|
87
|
+
at: readonly SecretPlace[];
|
|
88
|
+
environments: ReadonlyMap<string, EnvironmentRow>;
|
|
89
|
+
needs: readonly (readonly Permission[] | null)[];
|
|
90
|
+
owns?: readonly boolean[];
|
|
91
|
+
},
|
|
92
|
+
): (RefusalCode | null)[] {
|
|
93
|
+
const { principal, permission, secrets, at, environments, needs } = call;
|
|
94
|
+
const gone = (place: SecretPlace) => environments.get(place.environmentId)?.deleted ?? false;
|
|
95
|
+
const refusalOf = (secret: SecretPlace, i: number): RefusalCode | null => {
|
|
96
|
+
const personal = personalRefusal(reader, principal, permission, secret, at[i]!, environments);
|
|
97
|
+
if (personal !== undefined) return personal;
|
|
98
|
+
const also = needs[i];
|
|
99
|
+
if (also != null) {
|
|
100
|
+
const place = placeIn(at[i]!, environments);
|
|
101
|
+
const refused = also.map((need) => refuses(reader, need, place, gone(secret) || gone(at[i]!)));
|
|
102
|
+
// Any one of none is none held: refused.
|
|
103
|
+
if (refused.every((code) => code !== null)) return refused[0] ?? 'no_grant';
|
|
104
|
+
}
|
|
105
|
+
// Through a reference, the holder's grants decide, its source's never: a reference takes its value and nature
|
|
106
|
+
// (a sensitive one stays use-only) from its source, and its permissions from where it lives. Its maker held what
|
|
107
|
+
// the source's nature asks when it was made (`reference`).
|
|
108
|
+
return refuses(reader, permission, placeIn(at[i]!, environments), gone(secret) || gone(at[i]!));
|
|
109
|
+
};
|
|
110
|
+
// A loosening takes its reader owning the project besides, a personal config's owner too, there.
|
|
111
|
+
return secrets.map((secret, i) => {
|
|
112
|
+
const code = refusalOf(secret, i);
|
|
113
|
+
return code === null && call.owns?.[i] === true && !ownsProject(reader.live, secret.projectId) ? 'not_allowed' : code;
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Each key's refusal once its sensitivity is weighed too, after `vet`. A
|
|
119
|
+
* sensitive value is only ever used for a person, never handed back, by
|
|
120
|
+
* whatever credential of theirs asks; and handed back to a service account
|
|
121
|
+
* only on a credential an Owner of its own project issued, a reference's
|
|
122
|
+
* source's too, whose value it is (`readsSensitive`, R32). And a fork
|
|
123
|
+
* copies one, for anyone, a service account too, only where its app says
|
|
124
|
+
* it keeps it sensitive (an app of 0.9, mid-deploy, would write the copy as
|
|
125
|
+
* a secret its people read), and never one read through a reference.
|
|
126
|
+
*/
|
|
127
|
+
export function sensitiveRefusals(
|
|
128
|
+
codes: readonly (RefusalCode | null)[],
|
|
129
|
+
reader: Reader,
|
|
130
|
+
call: {
|
|
131
|
+
action: 'secret.read' | 'key.wrap' | 'key.rewrap';
|
|
132
|
+
principal: string;
|
|
133
|
+
purpose?: string;
|
|
134
|
+
keepsSensitive?: boolean;
|
|
135
|
+
secrets: readonly { projectId: string; sensitive?: boolean }[];
|
|
136
|
+
/** Each secret read through a reference. */
|
|
137
|
+
viaReference?: readonly boolean[];
|
|
138
|
+
},
|
|
139
|
+
): (RefusalCode | null)[] {
|
|
140
|
+
const copying = call.action === 'secret.read' && call.purpose === 'copy';
|
|
141
|
+
const handedBack = call.action === 'secret.read' && handsBack({ purpose: call.purpose ?? '', keepsSensitive: call.keepsSensitive === true });
|
|
142
|
+
const person = call.principal.startsWith('user:');
|
|
143
|
+
return call.secrets.map((secret, i) => {
|
|
144
|
+
const code = codes[i] ?? null;
|
|
145
|
+
if (code !== null || secret.sensitive !== true) return code;
|
|
146
|
+
// A sensitive value read through a reference is never copied: a fork keeps it a reference to its source, so the
|
|
147
|
+
// value stays in its own project, where R16/R32 decide who reads it. Copying it would move it to the fork's.
|
|
148
|
+
if ((copying && call.keepsSensitive !== true) || (copying && call.viaReference?.[i] === true) || (handedBack && person)) return 'sensitive';
|
|
149
|
+
if (handedBack && !readsSensitive(reader.live, secret.projectId)) return 'not_owner_issued';
|
|
150
|
+
return null;
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Each key's refusal once a workload's listing is weighed too, after `vet`
|
|
156
|
+
* and before its sensitivity (docs/design/workload-access.md, section 6.1).
|
|
157
|
+
* A workload is handed back, for any purpose but the proxy's use, only a
|
|
158
|
+
* value its credential lists where the value is held (`useOnly`): each
|
|
159
|
+
* secret is that place, a reference's source, a fork's parent, whatever
|
|
160
|
+
* place the call checked its permission at. A copy too, kept sensitive or
|
|
161
|
+
* not: it would land where the workload may read it. The vault opens no
|
|
162
|
+
* plain value, so every key here is a secret or a sensitive one.
|
|
163
|
+
*/
|
|
164
|
+
export function useOnlyRefusals(
|
|
165
|
+
codes: readonly (RefusalCode | null)[],
|
|
166
|
+
reader: Reader,
|
|
167
|
+
call: { action: 'secret.read' | 'key.wrap' | 'key.rewrap'; purpose?: string; secrets: readonly HeldKey[] },
|
|
168
|
+
): (RefusalCode | null)[] {
|
|
169
|
+
const handedBack = call.action === 'secret.read' && call.purpose !== 'use';
|
|
170
|
+
return call.secrets.map((secret, i) => codes[i] ?? (handedBack && useOnly(reader.live, secret) ? 'use_only' : null));
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Why `reader` may not make a reference at `holder` to `source`, or null, past
|
|
175
|
+
* what only the database settles (a source of another claim, a reference to
|
|
176
|
+
* itself). A personal config's values are bound to its owner: no reference
|
|
177
|
+
* holds one, nor reads one. Making one hands its source's value to whoever
|
|
178
|
+
* reads the holder, whatever its nature becomes: its maker writes the holder,
|
|
179
|
+
* reads the source there, as any direct access, and, for a sensitive one,
|
|
180
|
+
* which nobody reads, uses it there too (R24). A workload refers only to a
|
|
181
|
+
* value it is handed back, a plain one or one it lists (`useOnly`): it
|
|
182
|
+
* would otherwise move a value it uses to a place whose keys it reads.
|
|
183
|
+
*/
|
|
184
|
+
export function referenceRefusal(
|
|
185
|
+
reader: Reader,
|
|
186
|
+
holder: SecretPlace,
|
|
187
|
+
source: HeldKey,
|
|
188
|
+
environments: ReadonlyMap<string, EnvironmentRow>,
|
|
189
|
+
nature: 'plain' | 'secret' | 'sensitive',
|
|
190
|
+
): RefusalCode | null {
|
|
191
|
+
if (environments.get(holder.environmentId)?.owner != null || environments.get(source.environmentId)?.owner != null) return 'personal';
|
|
192
|
+
const gone = (place: SecretPlace) => environments.get(place.environmentId)?.deleted ?? false;
|
|
193
|
+
const at = placeIn(source, environments);
|
|
194
|
+
return refuses(reader, 'secret.write', placeIn(holder, environments), gone(holder))
|
|
195
|
+
?? refuses(reader, 'secret.read', at, gone(source))
|
|
196
|
+
?? (nature === 'sensitive' ? refuses(reader, 'secret.use', at, gone(source)) : null)
|
|
197
|
+
?? (nature !== 'plain' && useOnly(reader.live, source) ? 'use_only' : null);
|
|
198
|
+
}
|
package/src/store.ts
CHANGED
|
@@ -472,8 +472,10 @@ export async function natures(db: Queryable, secretIds: readonly string[]): Prom
|
|
|
472
472
|
|
|
473
473
|
/**
|
|
474
474
|
* For each version a rewrap would bring back into its secret, its nature
|
|
475
|
-
*
|
|
476
|
-
*
|
|
475
|
+
* when it is an old value, no looser than it was stored nor than its value
|
|
476
|
+
* was made since (the newest version of its very ciphertext), not the
|
|
477
|
+
* secret's current one; null for the current one, which a mark wraps again
|
|
478
|
+
* with no value changing.
|
|
477
479
|
*/
|
|
478
480
|
export async function restoredNatures(
|
|
479
481
|
db: Queryable,
|
|
@@ -483,7 +485,11 @@ export async function restoredNatures(
|
|
|
483
485
|
const { secrets, secretVersions } = tablesOf(db);
|
|
484
486
|
const [current, versions] = await Promise.all([
|
|
485
487
|
db.select({ id: secrets.id, version: secrets.currentVersionId }).from(secrets).where(inArray(secrets.id, [...new Set(items.map((item) => item.secretId))])),
|
|
486
|
-
db.select({
|
|
488
|
+
db.select({
|
|
489
|
+
id: secretVersions.id,
|
|
490
|
+
sensitive: sql<boolean | number>`(${secretVersions.sensitive} OR (${heldAs(secretVersions, sql`same.sensitive`)}))`,
|
|
491
|
+
plain: sql<boolean | number>`(${secretVersions.plaintext} IS NOT NULL AND (${heldAs(secretVersions, sql`same.plaintext IS NOT NULL`)}))`,
|
|
492
|
+
}).from(secretVersions)
|
|
487
493
|
.where(inArray(secretVersions.id, [...new Set(items.map((item) => item.from))])),
|
|
488
494
|
]);
|
|
489
495
|
const currentOf = new Map(current.map((row) => [row.id, row.version]));
|
|
@@ -666,6 +672,12 @@ export type SecretVersion = {
|
|
|
666
672
|
current: boolean;
|
|
667
673
|
/** Whether its secret's newest version is sensitive: what a rewrap of it may not loosen. */
|
|
668
674
|
newestSensitive: boolean;
|
|
675
|
+
/**
|
|
676
|
+
* Whether its value was made sensitive since: the newest version of its
|
|
677
|
+
* secret with its very ciphertext, which a mark in place or a restore wraps
|
|
678
|
+
* again unchanged, is. What a rewrap of it may not loosen either.
|
|
679
|
+
*/
|
|
680
|
+
heldSensitive: boolean;
|
|
669
681
|
};
|
|
670
682
|
|
|
671
683
|
export async function versions(db: Queryable, ids: readonly string[]): Promise<SecretVersion[]> {
|
|
@@ -691,6 +703,7 @@ export async function versions(db: Queryable, ids: readonly string[]): Promise<S
|
|
|
691
703
|
// The newest by the versions' own order: the app may write `current_version_id`, never a version.
|
|
692
704
|
newest: sql<number | string>`(SELECT max(newest.version) FROM secret_versions newest WHERE newest.secret_id = ${secretVersions.secretId})`,
|
|
693
705
|
newestSensitive: sql<boolean | number>`(SELECT newest.sensitive FROM secret_versions newest WHERE newest.secret_id = ${secretVersions.secretId} ORDER BY newest.version DESC LIMIT 1)`,
|
|
706
|
+
heldSensitive: sql<boolean | number>`(${heldAs(secretVersions, sql`same.sensitive`)})`,
|
|
694
707
|
})
|
|
695
708
|
.from(secretVersions)
|
|
696
709
|
.innerJoin(secrets, eq(secrets.id, secretVersions.secretId))
|
|
@@ -717,5 +730,15 @@ export async function versions(db: Queryable, ids: readonly string[]): Promise<S
|
|
|
717
730
|
current: row.currentVersionId === row.id && Number(row.newest) === row.version,
|
|
718
731
|
// SQLite answers a subquery's boolean as a number.
|
|
719
732
|
newestSensitive: Boolean(row.newestSensitive),
|
|
733
|
+
heldSensitive: Boolean(row.heldSensitive),
|
|
720
734
|
}));
|
|
721
735
|
}
|
|
736
|
+
|
|
737
|
+
/**
|
|
738
|
+
* `column` of the newest version of a version's secret with its very ciphertext: its value, as it is held now. Known
|
|
739
|
+
* by its IV and tag, which under one data key name one ciphertext, and which, unlike the ciphertext, no erasure empties.
|
|
740
|
+
*/
|
|
741
|
+
function heldAs(secretVersions: ReturnType<typeof tablesOf>['secretVersions'], column: SQL): SQL {
|
|
742
|
+
return sql`SELECT ${column} FROM secret_versions same WHERE same.secret_id = ${secretVersions.secretId}
|
|
743
|
+
AND same.iv = ${secretVersions.iv} AND same.auth_tag = ${secretVersions.authTag} ORDER BY same.version DESC LIMIT 1`;
|
|
744
|
+
}
|
package/src/vault.ts
CHANGED
|
@@ -23,6 +23,7 @@ import {
|
|
|
23
23
|
normalScope,
|
|
24
24
|
NOTHING,
|
|
25
25
|
ownsInstance,
|
|
26
|
+
ownsProject,
|
|
26
27
|
PROJECT_ONLY_PERMISSIONS,
|
|
27
28
|
ROLE_ID,
|
|
28
29
|
ROLES,
|
|
@@ -38,7 +39,6 @@ import {
|
|
|
38
39
|
type RoleDefinition,
|
|
39
40
|
type Scope,
|
|
40
41
|
type ServiceAccount,
|
|
41
|
-
withinCredential,
|
|
42
42
|
} from '@coffre/core/access';
|
|
43
43
|
import { GENESIS_HASH, verifyEntries, type LogKey, type StoredEntry } from '@coffre/core/audit';
|
|
44
44
|
import { checkContext, ownerKey, type SecretContext } from '@coffre/core/envelope';
|
|
@@ -60,7 +60,6 @@ import {
|
|
|
60
60
|
checkpointMessage,
|
|
61
61
|
checkpointVerifier,
|
|
62
62
|
describeAccessFault,
|
|
63
|
-
handsBack,
|
|
64
63
|
POLICY_REVISION,
|
|
65
64
|
policyRevision,
|
|
66
65
|
type Access,
|
|
@@ -104,6 +103,7 @@ import { everyProjectConversion, newestAccess, reportStands } from '@coffre/db/g
|
|
|
104
103
|
import { appendEntries, lockLogHead, missingEntry, newestAuthentic, type Appended, type Authenticity, type Newest, type NewEntry } from '@coffre/db/log';
|
|
105
104
|
|
|
106
105
|
import { signer, type Signer } from './checkpoint.ts';
|
|
106
|
+
import { keyRefusals, placeIn, referenceRefusal, refuses, sensitiveRefusals, useOnlyRefusals, type SecretPlace } from './decide.ts';
|
|
107
107
|
import type { ResolvedVaultConfig } from './config.ts';
|
|
108
108
|
import { carries, further, ownEntries, rehash, relink, START, vaultLogKey, VERIFY_BATCH, withCause, type Position } from './log.ts';
|
|
109
109
|
import { apply, replayFault, type LoggedMember, type Replayed } from './replay.ts';
|
|
@@ -318,7 +318,7 @@ class Outage {
|
|
|
318
318
|
}
|
|
319
319
|
|
|
320
320
|
/** A rewrap that would make a value less sensitive than it is. */
|
|
321
|
-
const LOOSER = 'a value is made less sensitive only by writing it again: an old version comes back, and a key is made stricter, as sensitive as its newest version or more';
|
|
321
|
+
const LOOSER = 'a value is made less sensitive only by writing it again: an old version comes back, and a key is made stricter, as sensitive as its newest version or more; only its current sensitive value is made a secret in place, by an Owner of its project';
|
|
322
322
|
|
|
323
323
|
function refusal(code: RefusalCode, message: string): Refusal {
|
|
324
324
|
return { code, message };
|
|
@@ -335,6 +335,8 @@ const MESSAGES: Record<RefusalCode, string> = {
|
|
|
335
335
|
ended: 'the reference read through was ended',
|
|
336
336
|
personal: "a personal config's values are its owner's alone",
|
|
337
337
|
sensitive: "a sensitive value is never shown to anyone, Owners included: `coffre run` gives your app a placeholder for it through the credential proxy, and a service account reads it",
|
|
338
|
+
not_owner_issued: 'a service account reads a sensitive value only on a token or trust binding an Owner of its project issued',
|
|
339
|
+
use_only: "a workload uses this value through coffre and never holds it: list it in its binding's reads, where the value is held, if it must",
|
|
338
340
|
not_allowed: 'not allowed to change this',
|
|
339
341
|
last_owner: 'the last Owner of the whole instance stays one: make someone else an Owner first',
|
|
340
342
|
invalid: 'not something the rules allow',
|
|
@@ -396,13 +398,21 @@ type Correlation = { requestId?: string | null; operationId?: string | null; cre
|
|
|
396
398
|
|
|
397
399
|
/**
|
|
398
400
|
* What the one asking holds through the credential their request came in
|
|
399
|
-
* on: no more than its scope (`allows`)
|
|
400
|
-
*
|
|
401
|
+
* on: no more than its scope (`allows`), and, a workload's, handed back
|
|
402
|
+
* only what the scope lists (`useOnly`). A scope that is not one, a kind of
|
|
403
|
+
* caller its principal cannot be, or a workload with no scope to list what
|
|
404
|
+
* it reads, is a bug of the app's, which fails the call. None said is an
|
|
405
|
+
* app older than 0.11, which has no workloads (`Through`).
|
|
401
406
|
*/
|
|
402
|
-
function through(standing: Standing, { credentialScope }: Through): Standing {
|
|
403
|
-
|
|
404
|
-
if (!
|
|
405
|
-
|
|
407
|
+
function through(standing: Standing, { credentialScope, caller }: Through): Standing {
|
|
408
|
+
const { principal } = standing;
|
|
409
|
+
if (caller !== undefined && !(caller === 'person' ? principal.startsWith('user:') : (caller === 'bearer' || caller === 'workload') && principal.startsWith('token:'))) {
|
|
410
|
+
throw new Error(`caller must be person for a user: principal, bearer or workload for a token: one, got: ${String(caller)}`);
|
|
411
|
+
}
|
|
412
|
+
if (caller === 'workload' && credentialScope == null) throw new Error('a workload caller comes with its credential scope, which lists what it reads');
|
|
413
|
+
if (credentialScope != null && !isCredentialScope(credentialScope)) throw new Error('credentialScope must be a list of grants, one a place');
|
|
414
|
+
const held = { ...(credentialScope == null ? {} : { credentialScope }), ...(caller === undefined ? {} : { kind: caller }) };
|
|
415
|
+
return { ...standing, live: { ...standing.live, ...held }, all: { ...standing.all, ...held } };
|
|
406
416
|
}
|
|
407
417
|
|
|
408
418
|
/** The credential a CI run's request came in on, as its entries record it: copied, never decided on. */
|
|
@@ -953,6 +963,8 @@ class VaultService implements Vault {
|
|
|
953
963
|
secrets: items.map((item) => item.secret),
|
|
954
964
|
// Through a reference, the reader's grants on the holder's environment decide.
|
|
955
965
|
places: sealed.map((seal, i) => seal?.holder ?? items[i]!.secret),
|
|
966
|
+
// Which items are read through a reference: a sensitive one is never copied, which would move it out of its project.
|
|
967
|
+
viaReference: sealed.map((seal) => seal !== null),
|
|
956
968
|
references: ends,
|
|
957
969
|
vet: ends.length === 0 ? undefined : async (_, reads) => sealed.map((seal) => (seal !== null && reads.ended.has(seal.seq) ? 'ended' : null)),
|
|
958
970
|
remote,
|
|
@@ -1020,16 +1032,22 @@ class VaultService implements Vault {
|
|
|
1020
1032
|
...keyEntry('key.rewrap', principal, source.secret, 'deny', 'bad_claim', input), secretVersionId: source.id,
|
|
1021
1033
|
})));
|
|
1022
1034
|
}
|
|
1023
|
-
// A value is made looser only by writing it again: a sensitive
|
|
1024
|
-
// is never wrapped again as anything but sensitive, so that a person who may not read it never makes it readable.
|
|
1025
|
-
|
|
1035
|
+
// A value is made looser only by writing it again: a sensitive value's key, or one of a secret whose newest version
|
|
1036
|
+
// is, is never wrapped again as anything but sensitive, so that a person who may not read it never makes it readable.
|
|
1037
|
+
// Nor a version whose value was made sensitive since, in place: the newest version of its very ciphertext. But for one:
|
|
1038
|
+
// a key's current sensitive value made a secret, in place, by an Owner of its project, who reads it anyway through a
|
|
1039
|
+
// service account they give read (R30). Older versions stay as they were held.
|
|
1040
|
+
const loosens = items.map((item, i) => item.secret.sensitive !== true && loaded.versions[i].secret.sensitive === true && loaded.versions[i].current);
|
|
1041
|
+
const strict = (i: number) => loaded.versions[i].secret.sensitive || loaded.versions[i].heldSensitive || loaded.versions[i].newestSensitive;
|
|
1042
|
+
if (items.some((item, i) => !loosens[i] && item.secret.sensitive !== true && strict(i))) {
|
|
1026
1043
|
return this.#refuseVersions(principal, 'invalid', loaded.versions.map((source) => ({
|
|
1027
1044
|
...keyEntry('key.rewrap', principal, source.secret, 'deny', 'invalid', input), secretVersionId: source.id,
|
|
1028
1045
|
})), LOOSER);
|
|
1029
1046
|
}
|
|
1030
1047
|
const sources = new Map(items.map((item, i) => [item.secret, loaded.versions[i]]));
|
|
1048
|
+
const loosened = new Set(items.flatMap((item, i) => (loosens[i] ? [item.secret] : [])));
|
|
1031
1049
|
const entry = (secret: SecretRef, decision: 'allow' | 'deny', code: string | null): NewEntry => ({
|
|
1032
|
-
...keyEntry('key.rewrap', principal, secret, decision, code, input, { from: sources.get(secret)!.secret.version }),
|
|
1050
|
+
...keyEntry('key.rewrap', principal, secret, decision, code, input, { from: sources.get(secret)!.secret.version, ...(loosened.has(secret) ? { was: 'sensitive' } : {}) }),
|
|
1033
1051
|
secretVersionId: sources.get(secret)!.id,
|
|
1034
1052
|
});
|
|
1035
1053
|
const remote =
|
|
@@ -1037,11 +1055,19 @@ class VaultService implements Vault {
|
|
|
1037
1055
|
return this.#keys(
|
|
1038
1056
|
{
|
|
1039
1057
|
action: 'key.rewrap', principal, permission: 'secret.write', secrets: items.map((item) => item.secret), remote, entry, input,
|
|
1040
|
-
// Again in the decision and under the log's head: a sensitive version written since it was loaded is as newest as any
|
|
1058
|
+
// Again in the decision and under the log's head: a sensitive version written since it was loaded is as newest as any,
|
|
1059
|
+
// a value made sensitive in place since is so, and a value loosened is still its key's current one.
|
|
1041
1060
|
vet: async (db) => {
|
|
1042
1061
|
const newest = await store.newestSensitive(db, items.map(({ secret }) => secret.secretId));
|
|
1043
|
-
|
|
1062
|
+
// Each source again: current as the decision had it, its key's pointer and its newest, and its value as held now.
|
|
1063
|
+
const again = new Map((await store.versions(db, items.map(({ secret }) => sources.get(secret)!.id))).map((version) => [version.id, version]));
|
|
1064
|
+
return items.map(({ secret }, i) => {
|
|
1065
|
+
const now = again.get(sources.get(secret)!.id);
|
|
1066
|
+
if (loosens[i]) return now?.current === true ? null : 'invalid';
|
|
1067
|
+
return secret.sensitive !== true && (newest.has(secret.secretId) || now?.heldSensitive === true) ? 'invalid' : null;
|
|
1068
|
+
});
|
|
1044
1069
|
},
|
|
1070
|
+
owns: loosens,
|
|
1045
1071
|
// An old value brought back, not the current one, is made live by whoever could have read it: a secret by its
|
|
1046
1072
|
// readers, a sensitive one, which nobody reads, by those who use it, a plain one by any writer, as it was shown
|
|
1047
1073
|
// to them. Whatever reads the key next, a reference to it included, then reads what its restorer could. Typing a
|
|
@@ -1153,6 +1179,10 @@ class VaultService implements Vault {
|
|
|
1153
1179
|
* is, read in the decision and again under the head; none for null.
|
|
1154
1180
|
*/
|
|
1155
1181
|
needs?: (db: Queryable) => Promise<(readonly Permission[] | null)[]>;
|
|
1182
|
+
/** Each secret that takes besides its reader being an Owner of its project (`ownsProject`): a sensitive value made a secret. */
|
|
1183
|
+
owns?: readonly boolean[];
|
|
1184
|
+
/** Which secrets are read through a reference, by index: a sensitive one of these is never copied (it would leave its project). */
|
|
1185
|
+
viaReference?: readonly boolean[];
|
|
1156
1186
|
remote: boolean;
|
|
1157
1187
|
entry: (secret: SecretRef, decision: 'allow' | 'deny', code: string | null) => NewEntry;
|
|
1158
1188
|
input: Correlation & Through & { purpose?: string; keepsSensitive?: boolean };
|
|
@@ -1183,37 +1213,16 @@ class VaultService implements Vault {
|
|
|
1183
1213
|
const at = secrets.map((secret, i) => call.places?.[i] ?? secret);
|
|
1184
1214
|
// Each a personal config is a config of too, whose read its owner needs (`places`).
|
|
1185
1215
|
const environments = d.reads.environments;
|
|
1186
|
-
const gone = (place: SecretPlace) => environments.get(place.environmentId)?.deleted ?? false;
|
|
1187
1216
|
owners.clear();
|
|
1188
1217
|
for (const [id, environment] of environments) if (environment.owner !== null) owners.set(id, ownerKey(environment.owner));
|
|
1189
1218
|
const needs = call.needs === undefined ? [] : await call.needs(d.tx);
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
const refused = also.map((permission) => refuses(reader, permission, place, gone(secret) || gone(at[i]!)));
|
|
1197
|
-
if (refused.every((code) => code !== null)) return refused[0]!;
|
|
1198
|
-
}
|
|
1199
|
-
// Through a reference, the holder's grants decide, its source's never: a reference takes its value and nature
|
|
1200
|
-
// (a sensitive one stays use-only) from its source, and its permissions from where it lives. Its maker held what
|
|
1201
|
-
// the source's nature asks when it was made (`reference`).
|
|
1202
|
-
return refuses(reader, call.permission, placeIn(at[i]!, environments), gone(secret) || gone(at[i]!));
|
|
1219
|
+
let codes = keyRefusals(reader, { principal, permission: call.permission, secrets, at, environments, needs, owns: call.owns });
|
|
1220
|
+
if (call.vet !== undefined && !codes.some((code) => code !== null)) codes = await call.vet(d.tx, d.reads);
|
|
1221
|
+
// Where each value is held, the secret itself, a reference's source or a fork's parent: what a workload lists.
|
|
1222
|
+
codes = useOnlyRefusals(codes, reader, { action, purpose: call.input.purpose, secrets });
|
|
1223
|
+
codes = sensitiveRefusals(codes, reader, {
|
|
1224
|
+
action, principal, purpose: call.input.purpose, keepsSensitive: call.input.keepsSensitive, secrets, viaReference: call.viaReference,
|
|
1203
1225
|
});
|
|
1204
|
-
if (call.vet !== undefined && !codes.some((code) => code !== null)) {
|
|
1205
|
-
(await call.vet(d.tx, d.reads)).forEach((code, i) => (codes[i] = code));
|
|
1206
|
-
}
|
|
1207
|
-
// A sensitive value is only ever used for a person, never handed back, by whatever credential of theirs asks. And
|
|
1208
|
-
// a fork copies one, for anyone, a service account too, only where its app says it keeps it sensitive: an app of
|
|
1209
|
-
// 0.9, mid-deploy, would write the copy as a secret its people read.
|
|
1210
|
-
const copying = action === 'secret.read' && call.input.purpose === 'copy';
|
|
1211
|
-
const handedBack = action === 'secret.read' && principal.startsWith('user:') && handsBack({ purpose: call.input.purpose ?? '', keepsSensitive: call.input.keepsSensitive === true });
|
|
1212
|
-
if (handedBack || (copying && call.input.keepsSensitive !== true)) {
|
|
1213
|
-
secrets.forEach((secret, i) => {
|
|
1214
|
-
if (codes[i] === null && secret.sensitive === true) codes[i] = 'sensitive';
|
|
1215
|
-
});
|
|
1216
|
-
}
|
|
1217
1226
|
let first = codes.find((code) => code !== null) ?? null;
|
|
1218
1227
|
if (first === null && action === 'secret.read' && this.#overBulkLimit(d)) first = 'bulk_limit';
|
|
1219
1228
|
if (first !== null) {
|
|
@@ -1413,19 +1422,11 @@ class VaultService implements Vault {
|
|
|
1413
1422
|
const source = sources.get(item.source.secretId);
|
|
1414
1423
|
return source === undefined ? [item.holder.environmentId] : [item.holder.environmentId, source.environmentId];
|
|
1415
1424
|
}));
|
|
1416
|
-
const gone = (place: SecretPlace) => environments.get(place.environmentId)?.deleted ?? false;
|
|
1417
1425
|
const codes = items.map((item): RefusalCode | null => {
|
|
1418
1426
|
const source = sources.get(item.source.secretId);
|
|
1419
1427
|
if (source === undefined || projectOf.get(item.holder.environmentId) !== item.holder.projectId) return 'bad_claim';
|
|
1420
1428
|
if (source.secretId === item.holder.secretId) return 'invalid';
|
|
1421
|
-
|
|
1422
|
-
if (environments.get(item.holder.environmentId)?.owner != null || environments.get(source.environmentId)?.owner != null) return 'personal';
|
|
1423
|
-
// Making one hands its source's value to whoever reads the holder, whatever its nature becomes: its maker reads
|
|
1424
|
-
// it at the source, as any direct access there, and, for a sensitive one, which nobody reads, uses it there too.
|
|
1425
|
-
const at = placeIn(source, environments);
|
|
1426
|
-
return refuses(reader, 'secret.write', placeIn(item.holder, environments), gone(item.holder))
|
|
1427
|
-
?? refuses(reader, 'secret.read', at, gone(source))
|
|
1428
|
-
?? (natures.get(source.secretId) === 'sensitive' ? refuses(reader, 'secret.use', at, gone(source)) : null);
|
|
1429
|
+
return referenceRefusal(reader, item.holder, source, environments, natures.get(source.secretId) ?? 'secret');
|
|
1429
1430
|
});
|
|
1430
1431
|
const first = codes.find((code) => code !== null) ?? null;
|
|
1431
1432
|
if (first !== null) throw new Refused(refusal(first, MESSAGES[first]), items.map((item, i) => entry(item, 'deny', codes[i] ?? first)));
|
|
@@ -2243,9 +2244,10 @@ class VaultService implements Vault {
|
|
|
2243
2244
|
next.protected = [...next.protected, ...change.environmentIds.filter((id) => !known.has(id)).map((environmentId) => ({ projectId: change.projectId, environmentId, at: d.at }))]
|
|
2244
2245
|
.sort((a, b) => (a.environmentId < b.environmentId ? -1 : 1));
|
|
2245
2246
|
} else {
|
|
2246
|
-
// Unprotecting widens what people hold there: an Owner of the
|
|
2247
|
-
|
|
2248
|
-
|
|
2247
|
+
// Unprotecting widens what people hold there: an Owner of the project's to do, or of the whole instance's (R30).
|
|
2248
|
+
// It takes off only what the project protects: a fork's id, with no row yet, is another project's to unprotect.
|
|
2249
|
+
if (!ownsProject(acting.live, change.projectId)) throw refused('not_allowed', 'only an Owner of its project, or of the whole instance, unprotects an environment');
|
|
2250
|
+
next.protected = next.protected.filter((place) => place.projectId !== change.projectId || !change.environmentIds.includes(place.environmentId));
|
|
2249
2251
|
}
|
|
2250
2252
|
} else {
|
|
2251
2253
|
throw refused('invalid', 'a change is to a role, an instance role, or protection');
|
|
@@ -3114,64 +3116,6 @@ function viaDetail(via: Via, seal: Sealed | null): Record<string, unknown> {
|
|
|
3114
3116
|
};
|
|
3115
3117
|
}
|
|
3116
3118
|
|
|
3117
|
-
/** An environment a secret is in, or that holds a reference to one. */
|
|
3118
|
-
type SecretPlace = { projectId: string; environmentId: string };
|
|
3119
|
-
|
|
3120
|
-
/**
|
|
3121
|
-
* Where a decision checks a permission: an environment, with its slug as
|
|
3122
|
-
* the store has it when it was read and is in the project claimed;
|
|
3123
|
-
* otherwise none, which no grant on a slug matches.
|
|
3124
|
-
*/
|
|
3125
|
-
function placeIn(place: SecretPlace, environments: ReadonlyMap<string, { projectId: string; slug: string }>): Place {
|
|
3126
|
-
const environment = environments.get(place.environmentId);
|
|
3127
|
-
const slug = environment?.projectId === place.projectId ? environment.slug : null;
|
|
3128
|
-
return { projectId: place.projectId, environmentId: place.environmentId, environmentSlug: slug };
|
|
3129
|
-
}
|
|
3130
|
-
|
|
3131
|
-
/**
|
|
3132
|
-
* Why `reader` may not do `permission` at `where`, or null if they may. A
|
|
3133
|
-
* deleted place is refused before any grant is asked, so no kind of grant,
|
|
3134
|
-
* on the project, the environment or every project, can reach into one.
|
|
3135
|
-
*/
|
|
3136
|
-
/**
|
|
3137
|
-
* A key of a personal config is its owner's alone (docs/design/forking.md,
|
|
3138
|
-
* section 4): no grant or role opens or wraps it, an Owner's included,
|
|
3139
|
-
* only its owner, while they read the environment it is a config of,
|
|
3140
|
-
* whatever the call is. Nor does any reference reach into or out of one.
|
|
3141
|
-
* Undefined for a key of any other environment, decided as ever.
|
|
3142
|
-
*/
|
|
3143
|
-
function personalRefusal(
|
|
3144
|
-
reader: Standing,
|
|
3145
|
-
principal: string,
|
|
3146
|
-
permission: Permission,
|
|
3147
|
-
secret: SecretPlace,
|
|
3148
|
-
at: SecretPlace,
|
|
3149
|
-
environments: ReadonlyMap<string, store.EnvironmentRow>,
|
|
3150
|
-
): RefusalCode | null | undefined {
|
|
3151
|
-
const own = environments.get(secret.environmentId);
|
|
3152
|
-
const held = at.environmentId === secret.environmentId ? own : environments.get(at.environmentId);
|
|
3153
|
-
if (own?.owner == null && held?.owner == null) return undefined;
|
|
3154
|
-
if (own !== held || principal !== own?.owner || own.parentId === null) return 'personal';
|
|
3155
|
-
const parent = placeIn({ projectId: secret.projectId, environmentId: own.parentId }, environments);
|
|
3156
|
-
// Its owner reads what they wrote while they read or use the environment it configures (R8).
|
|
3157
|
-
const gone = own.deleted || (environments.get(own.parentId)?.deleted ?? true);
|
|
3158
|
-
const refused = refuses(reader, 'secret.use', parent, gone) === null ? null : refuses(reader, 'secret.read', parent, gone);
|
|
3159
|
-
if (refused !== null) return refused;
|
|
3160
|
-
// The credential's scope caps the owner still: their viewer token on the parent reads, and wraps no key. Reading their
|
|
3161
|
-
// own values takes use there: a Contributor's handle reads what its owner wrote, and nothing of the parent's.
|
|
3162
|
-
return withinCredential(reader.live, permission === 'secret.read' ? 'secret.use' : permission, parent) ? null : 'no_grant';
|
|
3163
|
-
}
|
|
3164
|
-
|
|
3165
|
-
function refuses(reader: Standing, permission: Permission, where: Place, deleted: boolean): RefusalCode | null {
|
|
3166
|
-
if (reader.status === 'tampered') return 'tampered';
|
|
3167
|
-
if (reader.status === 'removed') return 'removed';
|
|
3168
|
-
if (reader.status === 'unknown') return 'not_a_member';
|
|
3169
|
-
if (deleted) return 'deleted';
|
|
3170
|
-
if (allows(reader.live, permission, where)) return null;
|
|
3171
|
-
// Would a grant that has lapsed have covered it?
|
|
3172
|
-
return allows(reader.all, permission, where) ? 'expired' : 'no_grant';
|
|
3173
|
-
}
|
|
3174
|
-
|
|
3175
3119
|
/**
|
|
3176
3120
|
* An entry about a key. A new secret's row is not committed when its key is
|
|
3177
3121
|
* wrapped, so a wrap names the secret in its payload; the others name it.
|