hammerkit 1.24.0 → 1.25.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.
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.checkoutRef = exports.isValidRef = void 0;
3
+ exports.resolveRef = exports.RefError = exports.checkoutRef = exports.isValidRef = void 0;
4
4
  const child_process_1 = require("child_process");
5
5
  const promises_1 = require("fs/promises");
6
6
  const util_1 = require("util");
@@ -20,7 +20,10 @@ exports.isValidRef = isValidRef;
20
20
  * caller's. Credentials come from the git configuration of the server, there is no
21
21
  * prompt a run could hang on.
22
22
  */
23
- async function checkoutRef(repository, ref, directory, env, signal) {
23
+ async function checkoutRef(repository, ref, directory, env, signal,
24
+ // the commit the ref was resolved to when the run was submitted: a ref that moved since
25
+ // is not what was approved
26
+ expectedCommit) {
24
27
  if (!isValidRef(ref)) {
25
28
  throw new Error(`${JSON.stringify(ref.slice(0, 80))} is not a branch, tag or commit`);
26
29
  }
@@ -29,9 +32,58 @@ async function checkoutRef(repository, ref, directory, env, signal) {
29
32
  await run(['init', '--quiet', directory]);
30
33
  await run(['-C', directory, 'fetch', '--quiet', '--depth', '1', '--', repository, ref]);
31
34
  await run(['-C', directory, '-c', 'advice.detachedHead=false', 'checkout', '--quiet', 'FETCH_HEAD']);
32
- return (await run(['-C', directory, 'rev-parse', 'HEAD'])).trim();
35
+ const commit = (await run(['-C', directory, 'rev-parse', 'HEAD'])).trim();
36
+ if (expectedCommit && commit !== expectedCommit) {
37
+ throw new Error(`${ref} is at ${commit} now, the run was submitted for ${expectedCommit}`);
38
+ }
39
+ return commit;
33
40
  }
34
41
  exports.checkoutRef = checkoutRef;
42
+ class RefError extends Error {
43
+ }
44
+ exports.RefError = RefError;
45
+ const COMMIT = /^[0-9a-f]{40}([0-9a-f]{24})?$/;
46
+ /**
47
+ * The commit a branch or tag of a repository points to now: `refs/heads/<ref>`, else
48
+ * `refs/tags/<ref>` (the commit of an annotated tag). A name that is both a branch and
49
+ * a tag is ambiguous. A full commit hash is taken as it is.
50
+ */
51
+ async function resolveRef(repository, ref, env, signal) {
52
+ if (!isValidRef(ref)) {
53
+ throw new RefError(`${JSON.stringify(ref.slice(0, 80))} is not a branch, tag or commit`);
54
+ }
55
+ if (COMMIT.test(ref.toLowerCase())) {
56
+ return ref.toLowerCase();
57
+ }
58
+ const output = await git([
59
+ 'ls-remote',
60
+ '--heads',
61
+ '--tags',
62
+ '--',
63
+ repository,
64
+ `refs/heads/${ref}`,
65
+ `refs/tags/${ref}`,
66
+ `refs/tags/${ref}^{}`,
67
+ ], env, signal, `the refs of ${repository}`);
68
+ const found = new Map();
69
+ for (const line of output.split('\n')) {
70
+ const [commit, name] = line.split('\t');
71
+ if (commit && name) {
72
+ found.set(name, commit);
73
+ }
74
+ }
75
+ const branch = found.get(`refs/heads/${ref}`);
76
+ const tag = found.get(`refs/tags/${ref}^{}`) ?? found.get(`refs/tags/${ref}`);
77
+ if (branch && tag) {
78
+ throw new RefError(`${ref} is both a branch and a tag, name one of them`);
79
+ }
80
+ const commit = branch ?? tag;
81
+ if (!commit) {
82
+ throw new RefError(`there is no branch or tag ${ref}`);
83
+ }
84
+ return commit;
85
+ }
86
+ exports.resolveRef = resolveRef;
35
87
  async function git(args, baseEnv, signal, what) {
36
88
  const env = {};
37
89
  for (const [key, value] of Object.entries(baseEnv)) {
@@ -0,0 +1,296 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.inspectBundle = exports.planBundle = exports.DefinitionError = void 0;
4
+ const promises_1 = require("fs/promises");
5
+ const os_1 = require("os");
6
+ const path_1 = require("path");
7
+ const yaml_1 = require("yaml");
8
+ const get_file_context_1 = require("../file/get-file-context");
9
+ const resolve_git_source_1 = require("../includes/resolve-git-source");
10
+ const schema_parser_1 = require("../schema/schema-parser");
11
+ const reference_parser_1 = require("../schema/reference-parser");
12
+ const work_scope_parser_1 = require("../schema/work-scope-parser");
13
+ const work_task_1 = require("../planner/work-task");
14
+ const work_service_1 = require("../planner/work-service");
15
+ const log_1 = require("../log");
16
+ const parse_error_1 = require("../schema/parse-error");
17
+ class DefinitionError extends Error {
18
+ }
19
+ exports.DefinitionError = DefinitionError;
20
+ // 40 (sha-1) or 64 (sha-256) hex digits
21
+ const COMMIT_PATTERN = /^[0-9a-f]{40}([0-9a-f]{24})?$/i;
22
+ // A hash alone can only name what is inside the bundle, so everything a build
23
+ // file pulls in from elsewhere has to be pinned: a git include needs a commit
24
+ // and a remote that is not a path on the server.
25
+ function findLooseIncludes(bundle, noRemote = false) {
26
+ const problems = [];
27
+ for (const [path, content] of Object.entries(bundle.files)) {
28
+ if (!/\.ya?ml$/i.test(path)) {
29
+ continue;
30
+ }
31
+ let document;
32
+ try {
33
+ document = (0, yaml_1.parse)(content);
34
+ }
35
+ catch {
36
+ continue; // a file that isn't valid yaml is reported when the bundle is parsed
37
+ }
38
+ for (const block of ['includes', 'references']) {
39
+ for (const [name, include] of Object.entries(document?.[block] ?? {})) {
40
+ const source = include;
41
+ if (!source || typeof source !== 'object' || source.git === undefined) {
42
+ continue;
43
+ }
44
+ if (noRemote) {
45
+ problems.push(`${path}: ${block}.${name}: a proposal may not include from git, the server would fetch it before anybody approved it`);
46
+ continue;
47
+ }
48
+ if (typeof source.git !== 'string' || !/^(https:\/\/|ssh:\/\/|git@[\w.-]+:)/.test(source.git)) {
49
+ problems.push(`${path}: ${block}.${name}: git has to be an https:// or ssh:// address`);
50
+ }
51
+ if (typeof source.ref !== 'string' || !COMMIT_PATTERN.test(source.ref)) {
52
+ problems.push(`${path}: ${block}.${name}: pin ref to a commit, a branch or tag can move`);
53
+ }
54
+ }
55
+ }
56
+ }
57
+ return problems;
58
+ }
59
+ // What a build file may not name on a server: the places it would run or store things
60
+ // are the operator's. The docker host, a Kubernetes cluster and a remote cache are
61
+ // credentials of the machine, not of the build file, and a project file that could carry
62
+ // them could send runs, or the cache, anywhere.
63
+ function findHostProblems(bundle) {
64
+ const problems = [];
65
+ for (const [path, content] of Object.entries(bundle.files)) {
66
+ if (!/\.ya?ml$/i.test(path)) {
67
+ continue;
68
+ }
69
+ let document;
70
+ try {
71
+ document = (0, yaml_1.parse)(content);
72
+ }
73
+ catch {
74
+ continue; // reported when the bundle is parsed
75
+ }
76
+ if (!document || typeof document !== 'object') {
77
+ continue;
78
+ }
79
+ if (document.environments && Object.keys(document.environments).length > 0) {
80
+ problems.push(`${path}: environments pick a docker host or a Kubernetes cluster, which are the server's to choose, remove them`);
81
+ }
82
+ for (const [name, cache] of Object.entries(document.caches ?? {})) {
83
+ if (cache?.backend?.type !== 'local' || cache.backend.path !== undefined) {
84
+ problems.push(`${path}: caches.${name}: a server keeps its own cache, a build file names no backend or path`);
85
+ }
86
+ }
87
+ for (const [name, service] of Object.entries(document.services ?? {})) {
88
+ if (service &&
89
+ typeof service === 'object' &&
90
+ ('selector' in service || 'kubeconfig' in service || 'context' in service)) {
91
+ problems.push(`${path}: services.${name}: Kubernetes services are not supported on a server`);
92
+ }
93
+ }
94
+ }
95
+ return problems;
96
+ }
97
+ // What a build file may read while it is inspected: its own files. The rest of
98
+ // the server is not its business (a missing file is "not there", not an error).
99
+ function confined(files, root) {
100
+ const check = (path) => {
101
+ if (!(0, resolve_git_source_1.isWithin)(root, (0, path_1.resolve)(root, path))) {
102
+ throw new DefinitionError(`${path} is outside of the bundle`);
103
+ }
104
+ };
105
+ const guarded = (fn) => (path) => {
106
+ check(path);
107
+ return fn.call(files, path);
108
+ };
109
+ return {
110
+ ...files,
111
+ read: guarded(files.read),
112
+ readStream: guarded(files.readStream),
113
+ listFiles: guarded(files.listFiles),
114
+ stats: guarded(files.stats),
115
+ exists: async (path) => (0, resolve_git_source_1.isWithin)(root, (0, path_1.resolve)(root, path)) && files.exists(path),
116
+ watch: files.watch,
117
+ };
118
+ }
119
+ // A path of a task, written relative to the root of the bundle with forward
120
+ // slashes. A leading `!` (an exclusion) is kept. Nothing is returned for a path
121
+ // outside of the bundle.
122
+ function insideBundle(root, cwd, path) {
123
+ const negated = path.startsWith('!');
124
+ const absolute = (0, path_1.resolve)(cwd, negated ? path.slice(1) : path);
125
+ const inside = (0, path_1.relative)(root, absolute);
126
+ if (inside === '..' || inside.startsWith(`..${path_1.sep}`) || (0, path_1.isAbsolute)(inside)) {
127
+ return [];
128
+ }
129
+ const portable = inside.split(path_1.sep).join(path_1.posix.sep);
130
+ // a pattern keeps its glob characters, `resolve` only normalizes separators
131
+ return [`${negated ? '!' : ''}${portable === '' ? '.' : portable}`];
132
+ }
133
+ function itemsOf(workTree) {
134
+ return [...Object.values(workTree.tasks), ...Object.values(workTree.services)];
135
+ }
136
+ function secretsOf(item) {
137
+ return 'secrets' in item.data ? item.data.secrets : [];
138
+ }
139
+ function describeSecret(secret) {
140
+ const target = secret.target.type === 'env' ? secret.target.name : secret.target.path;
141
+ const source = secret.source;
142
+ if (source.type === 'provider') {
143
+ return { from: `${source.provider}:${source.ref}`, account: source.account, target };
144
+ }
145
+ return { from: source.type === 'env' ? `env:${source.name}` : `file:${source.path}`, account: null, target };
146
+ }
147
+ // What a server refuses to publish. A secret that reads a file or a variable of the
148
+ // server has no account, so nothing would stand between the build file and the
149
+ // operator's own credentials.
150
+ function findPolicyProblems(items, policy, root) {
151
+ const problems = [];
152
+ for (const item of items) {
153
+ const runtime = item.data.type === 'local-task' ? 'local' : 'docker';
154
+ if (policy.runtimes && !policy.runtimes.includes(runtime)) {
155
+ problems.push(`${item.name}: runs on the ${runtime} runtime, which this server does not allow (runtimes: ${policy.runtimes.join(', ')})`);
156
+ }
157
+ const data = item.data;
158
+ if ((0, work_task_1.isContainerWorkTask)(data) || (0, work_service_1.isContainerWorkService)(data)) {
159
+ for (const mount of data.mounts) {
160
+ // a path of the server is not the build file's to mount: the docker socket, /etc, a home directory
161
+ if (insideBundle(root, data.cwd, mount.localPath).length === 0) {
162
+ problems.push(`${item.name}: mounts ${mount.localPath}, which is outside of the bundle`);
163
+ }
164
+ }
165
+ }
166
+ for (const secret of secretsOf(item)) {
167
+ const source = secret.source;
168
+ if (source.type === 'provider' && policy.reserved?.includes(source.account)) {
169
+ problems.push(`${item.name}: the secret ${describeSecret(secret).target} is read as ${source.account}, which the server uses to read repositories`);
170
+ }
171
+ else if (source.type === 'file') {
172
+ problems.push(`${item.name}: a secret can not read a file of the server (file:), use a secret provider`);
173
+ }
174
+ else if (source.type === 'env' && !policy.envs.includes(source.name)) {
175
+ problems.push(`${item.name}: the secret ${describeSecret(secret).target} reads env:${source.name}, which the server does not list in runs.env.allow`);
176
+ }
177
+ }
178
+ }
179
+ return problems;
180
+ }
181
+ function toPlan(root, item) {
182
+ const data = item.data;
183
+ const secrets = secretsOf(item).map(describeSecret);
184
+ const accounts = [...new Set(secrets.flatMap((secret) => (secret.account ? [secret.account] : [])))].sort();
185
+ const mounts = (0, work_task_1.isContainerWorkTask)(data) || (0, work_service_1.isContainerWorkService)(data)
186
+ ? data.mounts.map((mount) => {
187
+ const inside = insideBundle(root, data.cwd, mount.localPath);
188
+ return `${inside.length > 0 ? inside[0] : mount.localPath}:${mount.containerPath}${mount.readOnly ? ':ro' : ''}`;
189
+ })
190
+ : [];
191
+ const ports = 'ports' in data ? data.ports.map((port) => `${port.hostPort ?? ''}:${port.containerPort}`) : [];
192
+ const flags = [];
193
+ if (data.type === 'local-task') {
194
+ flags.push('local-task');
195
+ }
196
+ if ('ports' in data && data.ports.some((port) => port.hostPort !== null)) {
197
+ flags.push('host-port');
198
+ }
199
+ if (data.scope.remote) {
200
+ flags.push('remote-include');
201
+ }
202
+ const isTask = data.type === 'local-task' || data.type === 'container-task';
203
+ return {
204
+ kind: isTask ? 'task' : 'service',
205
+ runtime: data.type === 'local-task' ? 'local' : 'docker',
206
+ ...((0, work_task_1.isContainerWorkTask)(data) || (0, work_service_1.isContainerWorkService)(data) ? { image: data.image } : {}),
207
+ cmds: 'cmds' in data ? data.cmds.map((cmd) => cmd.cmd) : 'cmd' in data && data.cmd ? [data.cmd.cmd] : [],
208
+ deps: item.deps.map((dep) => dep.name).sort(),
209
+ needs: item.needs.map((need) => need.name).sort(),
210
+ accounts,
211
+ secrets,
212
+ mounts,
213
+ ports,
214
+ generates: 'generates' in data ? data.generates.map((generate) => generate.path) : [],
215
+ flags,
216
+ };
217
+ }
218
+ // A file that does not match the schema says where, not only that it does not.
219
+ function describeFailure(e) {
220
+ if (e instanceof parse_error_1.ParseError) {
221
+ const issue = e.zod.issues[0];
222
+ return `${e.buildFilePath}: ${issue.path.length > 0 ? `${issue.path.join('.')}: ` : ''}${issue.message}`;
223
+ }
224
+ return (0, log_1.getErrorMessage)(e);
225
+ }
226
+ // Writes the bundle into a directory of its own and reads it the way a run
227
+ // would, so a build file that doesn't parse is refused when it is published, not
228
+ // when somebody wants to run it. The same function answers what a run will do,
229
+ // with the scope of the run: which tasks, services and accounts it involves.
230
+ async function planBundle(bundle, environment, options = {}) {
231
+ const problems = [...findLooseIncludes(bundle, options.noRemote), ...(options.policy ? findHostProblems(bundle) : [])];
232
+ if (problems.length > 0) {
233
+ throw new DefinitionError(problems.join('\n'));
234
+ }
235
+ const directory = await (0, promises_1.mkdtemp)((0, path_1.join)((0, os_1.tmpdir)(), 'hammerkit-definition-'));
236
+ try {
237
+ for (const [path, content] of Object.entries(bundle.files)) {
238
+ await (0, promises_1.mkdir)((0, path_1.dirname)((0, path_1.join)(directory, path)), { recursive: true });
239
+ await (0, promises_1.writeFile)((0, path_1.join)(directory, path), content);
240
+ }
241
+ const inspected = {
242
+ ...environment,
243
+ cwd: directory,
244
+ file: confined((0, get_file_context_1.getFileContext)(directory), directory),
245
+ };
246
+ try {
247
+ const { ctx, scope } = await (0, schema_parser_1.createParseContext)((0, path_1.join)(directory, bundle.entry), inspected);
248
+ const context = await (0, reference_parser_1.parseReferences)(ctx, scope, inspected);
249
+ const workTree = (0, work_scope_parser_1.getWorkContext)(context, options.scope ?? { environmentName: null }, inspected);
250
+ const items = itemsOf(workTree);
251
+ if (options.policy) {
252
+ const policyProblems = findPolicyProblems(items, options.policy, directory);
253
+ if (policyProblems.length > 0) {
254
+ throw new DefinitionError(policyProblems.join('\n'));
255
+ }
256
+ }
257
+ const inputs = {};
258
+ for (const item of Object.values(workTree.tasks)) {
259
+ inputs[item.name] = {
260
+ // the sources a task inherits from its dependencies belong to those
261
+ src: item.data.src
262
+ .filter((source) => source.inherited === null)
263
+ .flatMap((source) => insideBundle(directory, item.data.cwd, source.source)),
264
+ mounts: (0, work_task_1.isContainerWorkTask)(item.data)
265
+ ? item.data.mounts.flatMap((mount) => insideBundle(directory, item.data.cwd, mount.localPath))
266
+ : [],
267
+ deps: item.deps.map((dep) => dep.name).sort(),
268
+ needs: item.needs.map((need) => need.name).sort(),
269
+ };
270
+ }
271
+ const plan = {};
272
+ for (const item of items) {
273
+ plan[item.name] = toPlan(directory, item);
274
+ }
275
+ const sorted = (record) => Object.fromEntries(Object.entries(record).sort(([a], [b]) => (a < b ? -1 : 1)));
276
+ return {
277
+ tasks: Object.keys(context.tasks).sort(),
278
+ services: Object.keys(context.services).sort(),
279
+ inputs: sorted(inputs),
280
+ plan: sorted(plan),
281
+ accounts: [...new Set(Object.values(plan).flatMap((entry) => entry.accounts))].sort(),
282
+ };
283
+ }
284
+ catch (e) {
285
+ throw new DefinitionError(describeFailure(e).split(directory).join('.'));
286
+ }
287
+ }
288
+ finally {
289
+ await (0, promises_1.rm)(directory, { recursive: true, force: true });
290
+ }
291
+ }
292
+ exports.planBundle = planBundle;
293
+ // Reads a bundle the way a run would; see planBundle.
294
+ const inspectBundle = (bundle, environment) => planBundle(bundle, environment);
295
+ exports.inspectBundle = inspectBundle;
296
+ //# sourceMappingURL=plan.js.map
@@ -1,6 +1,8 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.getGrant = exports.isAdmin = exports.maySeeDefinitions = exports.mayPropose = exports.mayPublish = exports.matches = void 0;
3
+ exports.decideRun = exports.getGrant = exports.usedAccounts = exports.mayYank = exports.canUse = exports.maySeeDefinitions = exports.mayPublish = exports.mayPropose = exports.mayRun = exports.matches = void 0;
4
+ const repo_1 = require("./repo");
5
+ const BUILDS_ORDER = ['run', 'propose', 'publish'];
4
6
  function matches(entitlement, principal) {
5
7
  if (entitlement.subject) {
6
8
  return entitlement.subject.iss === principal.iss && entitlement.subject.sub === principal.sub;
@@ -15,76 +17,122 @@ function matches(entitlement, principal) {
15
17
  return false;
16
18
  }
17
19
  exports.matches = matches;
18
- function mayPublish(grant, name) {
19
- return grant.publish.includes('*') || grant.publish.includes(name);
20
- }
21
- exports.mayPublish = mayPublish;
22
- function mayPropose(grant, name) {
23
- return grant.propose.includes('*') || grant.propose.includes(name);
20
+ function atLeast(grant, level) {
21
+ return grant.builds !== null && BUILDS_ORDER.indexOf(grant.builds) >= BUILDS_ORDER.indexOf(level);
24
22
  }
23
+ const mayRun = (grant) => atLeast(grant, 'run');
24
+ exports.mayRun = mayRun;
25
+ const mayPropose = (grant) => atLeast(grant, 'propose');
25
26
  exports.mayPropose = mayPropose;
27
+ const mayPublish = (grant) => atLeast(grant, 'publish');
28
+ exports.mayPublish = mayPublish;
26
29
  // A caller who can neither run nor publish anything sees nothing of the server's
27
- // build files either. Admins see them, they have to know what they approve.
30
+ // build files either. Someone who uses an account sees them: they have to know what
31
+ // they approve.
28
32
  function maySeeDefinitions(grant) {
29
- return grant.accounts.length > 0 || grant.request.length > 0 || grant.publish.length > 0 || grant.propose.length > 0;
33
+ return grant.builds !== null || Object.keys(grant.accounts).length > 0;
30
34
  }
31
35
  exports.maySeeDefinitions = maySeeDefinitions;
32
- // Admin of an account decides about the runs on it.
33
- function isAdmin(grant, account) {
34
- return account !== null && grant.accounts.includes(account);
36
+ // `use` includes deciding about the runs others request on the account.
37
+ function canUse(grant, account) {
38
+ return grant.accounts[account] === 'use';
39
+ }
40
+ exports.canUse = canUse;
41
+ // Pulling a version back is a safe thing to do, so it is open to who publishes the build
42
+ // file, and to who uses an account the version works with: they are the ones who
43
+ // answer for what runs as it.
44
+ function mayYank(grant, version) {
45
+ return (0, exports.mayPublish)(grant) || (version.accounts ?? []).some((account) => canUse(grant, account));
46
+ }
47
+ exports.mayYank = mayYank;
48
+ function usedAccounts(grant) {
49
+ return Object.keys(grant.accounts).filter((account) => grant.accounts[account] === 'use');
35
50
  }
36
- exports.isAdmin = isAdmin;
37
- // Default deny: a caller without a matching entitlement gets no account.
51
+ exports.usedAccounts = usedAccounts;
52
+ // Default deny: a caller without a matching entitlement gets nothing.
38
53
  function getGrant(entitlements, principal) {
39
54
  const matched = [];
40
- const accounts = [];
41
- const requested = [];
42
- const publish = [];
43
- const propose = [];
44
- let defaultAccount = null;
45
- let source = null;
55
+ const accounts = {};
56
+ const repos = [];
57
+ let builds = null;
46
58
  entitlements.forEach((entitlement, index) => {
47
59
  if (!matches(entitlement, principal)) {
48
60
  return;
49
61
  }
50
62
  matched.push(index);
51
- for (const account of entitlement.accounts) {
52
- if (!accounts.includes(account)) {
53
- accounts.push(account);
63
+ for (const [account, level] of Object.entries(entitlement.accounts)) {
64
+ if (accounts[account] !== 'use') {
65
+ accounts[account] = level;
54
66
  }
55
67
  }
56
- for (const account of entitlement.request ?? []) {
57
- if (!requested.includes(account)) {
58
- requested.push(account);
68
+ for (const repo of entitlement.repos) {
69
+ if (!repos.includes(repo)) {
70
+ repos.push(repo);
59
71
  }
60
72
  }
61
- for (const name of entitlement.publish ?? []) {
62
- if (!publish.includes(name)) {
63
- publish.push(name);
64
- }
73
+ if (entitlement.builds &&
74
+ (builds === null || BUILDS_ORDER.indexOf(entitlement.builds) > BUILDS_ORDER.indexOf(builds))) {
75
+ builds = entitlement.builds;
65
76
  }
66
- for (const name of entitlement.propose ?? []) {
67
- if (!propose.includes(name)) {
68
- propose.push(name);
77
+ });
78
+ return { accounts, repos, builds, matched };
79
+ }
80
+ exports.getGrant = getGrant;
81
+ const COMMIT = /^[0-9a-f]{40}([0-9a-f]{24})?$/i;
82
+ // The single place that decides what happens to a run that was submitted: it
83
+ // starts, waits for an approval, or is refused with the reason.
84
+ function decideRun(input) {
85
+ const { grant, accounts, catalog, source, approvable, readable } = input;
86
+ if (!(0, exports.mayRun)(grant)) {
87
+ return { type: 'refuse', status: 403, error: 'you are not entitled to run anything on this server' };
88
+ }
89
+ if (source.type === 'upload') {
90
+ return {
91
+ type: 'refuse',
92
+ status: 501,
93
+ error: 'runs from an upload are not supported by this server yet, use a git ref',
94
+ };
95
+ }
96
+ // the caller's own list first: what they may not name is not told whether the server can read it
97
+ if (!(0, repo_1.matchesAnyRepo)(grant.repos, source.repo)) {
98
+ return {
99
+ type: 'refuse',
100
+ status: 403,
101
+ error: grant.repos.length > 0
102
+ ? `you may not run from ${source.repo}, your repositories are ${grant.repos.join(', ')}`
103
+ : `you may not run from ${source.repo}, no entitlement of yours lists a repository`,
104
+ };
105
+ }
106
+ if (!readable(source.repo)) {
107
+ return { type: 'refuse', status: 400, error: `this server can not read ${source.repo}` };
108
+ }
109
+ const request = [];
110
+ for (const account of accounts) {
111
+ const level = grant.accounts[account];
112
+ if (level === undefined) {
113
+ return { type: 'refuse', status: 403, error: `you may not use the account ${account}, it needs use or request` };
114
+ }
115
+ const rules = catalog[account];
116
+ if (rules?.source && !rules.source.includes(source.type)) {
117
+ return { type: 'refuse', status: 403, error: `the account ${account} is not used by runs from ${source.type}` };
118
+ }
119
+ if (rules?.refs) {
120
+ if (COMMIT.test(source.ref) || !(0, repo_1.matchesRef)(rules.refs, source.ref)) {
121
+ return {
122
+ type: 'refuse',
123
+ status: 403,
124
+ error: `the account ${account} is only used by runs from ${rules.refs.join(', ')}`,
125
+ };
69
126
  }
70
127
  }
71
- defaultAccount = defaultAccount ?? entitlement.default ?? null;
72
- if (entitlement.source) {
73
- source = source ? source.filter((s) => entitlement.source?.includes(s)) : [...entitlement.source];
128
+ if (level === 'request') {
129
+ if (!approvable(account)) {
130
+ return { type: 'refuse', status: 409, error: `nobody can approve ${account}, no entitlement uses it` };
131
+ }
132
+ request.push(account);
74
133
  }
75
- });
76
- // admin wins over request when two entitlements give both
77
- const request = requested.filter((account) => !accounts.includes(account));
78
- const usable = [...accounts, ...request];
79
- return {
80
- accounts,
81
- request,
82
- publish,
83
- propose,
84
- default: defaultAccount ?? (usable.length === 1 ? usable[0] : null),
85
- source: source ?? ['upload', 'ref'],
86
- matched,
87
- };
134
+ }
135
+ return request.length > 0 ? { type: 'approval', accounts: request } : { type: 'run' };
88
136
  }
89
- exports.getGrant = getGrant;
137
+ exports.decideRun = decideRun;
90
138
  //# sourceMappingURL=policy.js.map
@@ -1,30 +1,42 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.handleProposals = void 0;
3
+ exports.handleProposals = exports.mayApprove = void 0;
4
4
  const zod_1 = require("zod");
5
5
  const bundle_1 = require("../remote/bundle");
6
6
  const definitions_1 = require("./definitions");
7
7
  const http_1 = require("./http");
8
8
  const oauth_1 = require("./oauth");
9
9
  const policy_1 = require("./policy");
10
+ const proposals_1 = require("./proposals");
10
11
  const server_config_1 = require("./server-config");
11
12
  const decisionSchema = zod_1.z.object({ reason: zod_1.z.string().max(500).optional() }).strict();
12
13
  const same = (a, b) => a.iss === b.iss && a.sub === b.sub;
14
+ // Whoever approves publishes it, and uses the accounts its proposer did not: a
15
+ // version that names an account is approved by somebody the account trusts.
16
+ const mayApprove = (grant, proposal) => (0, policy_1.mayPublish)(grant) && proposal.missing.every((account) => (0, policy_1.canUse)(grant, account));
17
+ exports.mayApprove = mayApprove;
13
18
  // What the caller is shown of a proposal: what it is, who proposed it and the
14
19
  // version it would replace, so the files can be compared.
15
20
  function toView(store, proposal, principal, grant) {
21
+ const latest = store.find(proposal.name, 'latest')?.hash ?? null;
22
+ const stale = proposal.state === 'waiting' && latest !== proposal.base;
16
23
  return {
17
24
  ...proposal,
18
- base: store.find(proposal.name, 'latest')?.hash ?? null,
25
+ // the version it follows is fixed when it is proposed; if another one was
26
+ // published since, it can only be rejected
27
+ latest,
28
+ stale,
19
29
  mine: same(proposal.proposedBy, principal),
20
- // approving needs the permission to publish the name, and somebody else's proposal
21
- canDecide: proposal.state === 'waiting' && (0, policy_1.mayPublish)(grant, proposal.name) && !same(proposal.proposedBy, principal),
30
+ // approving needs the permission to publish, the accounts the proposer lacked, and
31
+ // somebody else's proposal
32
+ canDecide: proposal.state === 'waiting' && !stale && (0, exports.mayApprove)(grant, proposal) && !same(proposal.proposedBy, principal),
22
33
  };
23
34
  }
24
35
  /**
25
36
  * New versions of build files that wait for an approval. Whoever may only
26
- * propose a version (`propose` on an entitlement) puts one here; someone who may
27
- * `publish` that name approves it, and it is published then.
37
+ * propose a version (`builds: propose`), or publishes one that names accounts they
38
+ * do not use, puts one here; someone who may publish, and uses those accounts,
39
+ * approves it, and it is published then.
28
40
  *
29
41
  * GET /v1/proposals the ones the caller proposed or may decide
30
42
  * GET /v1/proposals/<name>/<hash> one of them
@@ -40,7 +52,7 @@ async function handleProposals(config, store, proposals, log, principal, req, re
40
52
  const [, name, hash, action] = match;
41
53
  const grant = (0, policy_1.getGrant)(config.entitlements, principal);
42
54
  // what the caller neither proposed nor may decide does not exist for them
43
- const visible = (proposal) => same(proposal.proposedBy, principal) || (0, policy_1.mayPublish)(grant, proposal.name);
55
+ const visible = (proposal) => same(proposal.proposedBy, principal) || (0, policy_1.mayPublish)(grant);
44
56
  if (name === undefined) {
45
57
  if (req.method !== 'GET') {
46
58
  (0, http_1.send)(res, 405, { error: 'method not allowed' }, { allow: 'GET' });
@@ -91,8 +103,13 @@ async function handleProposals(config, store, proposals, log, principal, req, re
91
103
  }
92
104
  const outcome = action === 'approve' ? 'approved' : 'rejected';
93
105
  try {
94
- const result = await proposals.decide(principal, name, hash, outcome, (n) => (0, policy_1.mayPublish)(grant, n), async (bundle) => {
106
+ const result = await proposals.decide(principal, name, hash, outcome, (proposed) => (0, exports.mayApprove)(grant, proposed), async (bundle) => {
95
107
  const proposed = proposals.get(name, hash);
108
+ // looked at again here, just before it is published
109
+ const latest = store.find(name, 'latest')?.hash ?? null;
110
+ if (proposed && latest !== proposed.base) {
111
+ throw new proposals_1.StaleProposalError(proposed.base, latest);
112
+ }
96
113
  await store.publish(name, bundle, proposed?.proposedBy ?? principal, principal);
97
114
  }, body.data.reason);
98
115
  log(`${action} proposal=${name}@${hash} iss=${principal.iss} sub=${principal.sub} result=${result.status}${body.data.reason ? ` reason=${(0, oauth_1.quote)(body.data.reason)}` : ''}`);
@@ -111,6 +128,11 @@ async function handleProposals(config, store, proposals, log, principal, req, re
111
128
  }
112
129
  }
113
130
  catch (e) {
131
+ if (e instanceof proposals_1.StaleProposalError) {
132
+ log(`${action} proposal=${name}@${hash} iss=${principal.iss} sub=${principal.sub} result=stale`);
133
+ (0, http_1.send)(res, 409, { error: e.message });
134
+ return true;
135
+ }
114
136
  if (e instanceof definitions_1.DefinitionError) {
115
137
  (0, http_1.send)(res, 400, { error: `the build file does not load: ${e.message}` });
116
138
  return true;