@zgeoff/atc 2.17.0 → 2.20.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.
@@ -5,6 +5,7 @@ import { DEFAULT_MIGRATION_TABLE, Migrator } from 'kysely/migration';
5
5
  import type { Migration, MigrationProvider, MigrationResultSet } from 'kysely/migration';
6
6
  import type { IdempotencyState } from './idempotency-record';
7
7
  import type { MessageStatus } from './message-record';
8
+ import type { RuntimeAuthBindingState, RuntimeAuthGrantPhase } from './runtime-auth-binding';
8
9
  import type { MaterializationPhase } from './workspace-materialization';
9
10
 
10
11
  interface FleetTable {
@@ -118,6 +119,41 @@ interface WorkspaceMaterializationTable {
118
119
  withheld_env: string | null;
119
120
  }
120
121
 
122
+ // One host's runtime auth binding, keyed by the session that owns the imp.
123
+ // The rebind columns hold a rebind in flight or the one that failed, all
124
+ // null otherwise.
125
+ interface RuntimeAuthBindingTable {
126
+ host_key: string;
127
+ target: string;
128
+ target_identity: string;
129
+ imp_name: string;
130
+ imp_id: string | null;
131
+ revision: number;
132
+ binding_hash: string;
133
+ binding_json: string;
134
+ state: RuntimeAuthBindingState;
135
+ attempt_id: string;
136
+ imp_created_by_attempt: number;
137
+ rebind_revision: number | null;
138
+ rebind_binding_hash: string | null;
139
+ rebind_binding_json: string | null;
140
+ rebind_attempt_id: string | null;
141
+ created_at: number;
142
+ updated_at: number;
143
+ revoked_at: number | null;
144
+ }
145
+
146
+ // One secret's grant to a host's imp, keyed by the host and the secret.
147
+ interface RuntimeAuthGrantTable {
148
+ host_key: string;
149
+ secret: string;
150
+ revision: number;
151
+ attempt_id: string;
152
+ preexisting: number;
153
+ phase: RuntimeAuthGrantPhase;
154
+ updated_at: number;
155
+ }
156
+
121
157
  interface MessagesTable {
122
158
  id: string;
123
159
  atc_id: string;
@@ -148,6 +184,8 @@ export interface StateStoreSchema {
148
184
  session_owner: SessionOwnerTable;
149
185
  idempotency: IdempotencyTable;
150
186
  workspace_materialization: WorkspaceMaterializationTable;
187
+ runtime_auth_binding: RuntimeAuthBindingTable;
188
+ runtime_auth_grant: RuntimeAuthGrantTable;
151
189
  }
152
190
 
153
191
  // Every shape the fleet table has shipped with: the oldest carries only
@@ -472,6 +510,45 @@ const MIGRATIONS: Record<string, Migration> = {
472
510
  }
473
511
  },
474
512
  },
513
+ '025_create_runtime_auth': {
514
+ async up(db: Kysely<StateStoreSchema>) {
515
+ await db.schema
516
+ .createTable('runtime_auth_binding')
517
+ .ifNotExists()
518
+ .addColumn('host_key', 'text', (c) => c.primaryKey())
519
+ .addColumn('target', 'text', (c) => c.notNull())
520
+ .addColumn('target_identity', 'text', (c) => c.notNull())
521
+ .addColumn('imp_name', 'text', (c) => c.notNull())
522
+ .addColumn('imp_id', 'text')
523
+ .addColumn('revision', 'integer', (c) => c.notNull())
524
+ .addColumn('binding_hash', 'text', (c) => c.notNull())
525
+ .addColumn('binding_json', 'text', (c) => c.notNull())
526
+ .addColumn('state', 'text', (c) => c.notNull())
527
+ .addColumn('attempt_id', 'text', (c) => c.notNull())
528
+ .addColumn('imp_created_by_attempt', 'integer', (c) => c.notNull().defaultTo(0))
529
+ .addColumn('rebind_revision', 'integer')
530
+ .addColumn('rebind_binding_hash', 'text')
531
+ .addColumn('rebind_binding_json', 'text')
532
+ .addColumn('rebind_attempt_id', 'text')
533
+ .addColumn('created_at', 'integer', (c) => c.notNull())
534
+ .addColumn('updated_at', 'integer', (c) => c.notNull())
535
+ .addColumn('revoked_at', 'integer')
536
+ .execute();
537
+
538
+ await db.schema
539
+ .createTable('runtime_auth_grant')
540
+ .ifNotExists()
541
+ .addColumn('host_key', 'text', (c) => c.notNull())
542
+ .addColumn('secret', 'text', (c) => c.notNull())
543
+ .addColumn('revision', 'integer', (c) => c.notNull())
544
+ .addColumn('attempt_id', 'text', (c) => c.notNull())
545
+ .addColumn('preexisting', 'integer', (c) => c.notNull())
546
+ .addColumn('phase', 'text', (c) => c.notNull())
547
+ .addColumn('updated_at', 'integer', (c) => c.notNull())
548
+ .addPrimaryKeyConstraint('runtime_auth_grant_pk', ['host_key', 'secret'])
549
+ .execute();
550
+ },
551
+ },
475
552
  };
476
553
 
477
554
  const PROVIDER: MigrationProvider = {
@@ -0,0 +1,110 @@
1
+ import type { SessionID } from '../shared/session-id';
2
+
3
+ /**
4
+ * Where a session's runtime auth binding stands:
5
+ *
6
+ * - `provisioning`: an attempt is creating the imp or adding grants.
7
+ * - `ready`: every grant is in place and the session may launch.
8
+ * - `revoked`: atc withdrew the grants; the session never launches again
9
+ * until an explicit rebind.
10
+ * - `revocation_pending`: the binding is blocked, but impd has not yet
11
+ * confirmed every grant gone, so access may remain until the owner
12
+ * resolves it.
13
+ * - `rollback_pending`: a failed attempt left effects atc could not
14
+ * confirm removed.
15
+ * - `rebind_failed`: a rebind failed; the binding keeps its old revision,
16
+ * the failed one is kept beside it, and auth stays blocked until an
17
+ * explicit retry.
18
+ */
19
+ export type RuntimeAuthBindingState =
20
+ | 'provisioning'
21
+ | 'ready'
22
+ | 'revoked'
23
+ | 'revocation_pending'
24
+ | 'rollback_pending'
25
+ | 'rebind_failed';
26
+
27
+ /**
28
+ * A rebind's revision, the binding it moves to, and the attempt making it.
29
+ */
30
+ interface RuntimeAuthRebind {
31
+ readonly revision: number;
32
+ readonly bindingHash: string;
33
+ readonly bindingJSON: string;
34
+ readonly attemptID: string;
35
+ }
36
+
37
+ /**
38
+ * One host's runtime auth binding, keyed by the session that owns the imp;
39
+ * its sub-sessions share it. The binding holds secret names and rules,
40
+ * never a credential value.
41
+ */
42
+ export interface RuntimeAuthBinding {
43
+ readonly hostKey: SessionID;
44
+ readonly target: string;
45
+ readonly targetIdentity: string;
46
+ readonly impName: string;
47
+
48
+ // impd's id for the imp; null until the imp exists.
49
+ readonly impID: string | null;
50
+ readonly revision: number;
51
+ readonly bindingHash: string;
52
+ readonly bindingJSON: string;
53
+ readonly state: RuntimeAuthBindingState;
54
+
55
+ // The attempt that last provisioned this revision.
56
+ readonly attemptID: string;
57
+
58
+ // Whether that attempt created the imp, which only then may be destroyed
59
+ // in its rollback.
60
+ readonly impCreatedByAttempt: boolean;
61
+
62
+ // The next revision a rebind is adding, or the failed one it left; null
63
+ // when no rebind is in flight or failed.
64
+ readonly rebind: RuntimeAuthRebind | null;
65
+ readonly createdAt: number;
66
+ readonly updatedAt: number;
67
+ readonly revokedAt: number | null;
68
+ }
69
+
70
+ // The fields a binding rewrites as it moves between states; `rebind: null`
71
+ // clears a settled rebind.
72
+ export interface RuntimeAuthBindingUpdate {
73
+ readonly state?: RuntimeAuthBindingState;
74
+ readonly impID?: string;
75
+ readonly impCreatedByAttempt?: boolean;
76
+ readonly revision?: number;
77
+ readonly bindingHash?: string;
78
+ readonly bindingJSON?: string;
79
+ readonly attemptID?: string;
80
+ readonly rebind?: RuntimeAuthRebind | null;
81
+ readonly revokedAt?: number;
82
+ }
83
+
84
+ /**
85
+ * Where one secret's grant to a host's imp stands. `uncertain` is a grant a
86
+ * stopped daemon left `granting`, which impd's grant list settles on next
87
+ * use; `revocation_pending` is one atc could not confirm removed.
88
+ */
89
+ export type RuntimeAuthGrantPhase =
90
+ | 'granting'
91
+ | 'granted'
92
+ | 'uncertain'
93
+ | 'revoking'
94
+ | 'revoked'
95
+ | 'revocation_pending';
96
+
97
+ /**
98
+ * One secret's grant to a host's imp: the revision and attempt that added
99
+ * it, and whether impd held it before that attempt, which rules it out of
100
+ * the attempt's rollback.
101
+ */
102
+ export interface RuntimeAuthGrant {
103
+ readonly hostKey: SessionID;
104
+ readonly secret: string;
105
+ readonly revision: number;
106
+ readonly attemptID: string;
107
+ readonly preexisting: boolean;
108
+ readonly phase: RuntimeAuthGrantPhase;
109
+ readonly updatedAt: number;
110
+ }
@@ -24,6 +24,11 @@ import type { MessageOwner } from './message-owner';
24
24
  import type { MessageRecord } from './message-record';
25
25
  import { runMigrations } from './run-migrations';
26
26
  import type { StateStoreSchema } from './run-migrations';
27
+ import type {
28
+ RuntimeAuthBinding,
29
+ RuntimeAuthBindingUpdate,
30
+ RuntimeAuthGrant,
31
+ } from './runtime-auth-binding';
27
32
  import type { TrailEntry } from './trail-entry';
28
33
  import type {
29
34
  MaterializationUpdate,
@@ -951,6 +956,154 @@ export class StateStore {
951
956
  .execute();
952
957
  }
953
958
 
959
+ // Records a host's binding as its first attempt starts provisioning it,
960
+ // at the first revision, before the imp exists.
961
+ async createAuthBinding(
962
+ row: Pick<
963
+ RuntimeAuthBinding,
964
+ | 'hostKey'
965
+ | 'target'
966
+ | 'targetIdentity'
967
+ | 'impName'
968
+ | 'bindingHash'
969
+ | 'bindingJSON'
970
+ | 'attemptID'
971
+ >,
972
+ at: number,
973
+ ): Promise<void> {
974
+ await this.db
975
+ .insertInto('runtime_auth_binding')
976
+ .values({
977
+ host_key: row.hostKey,
978
+ target: row.target,
979
+ target_identity: row.targetIdentity,
980
+ imp_name: row.impName,
981
+ imp_id: null,
982
+ revision: 1,
983
+ binding_hash: row.bindingHash,
984
+ binding_json: row.bindingJSON,
985
+ state: 'provisioning',
986
+ attempt_id: row.attemptID,
987
+ imp_created_by_attempt: 0,
988
+ rebind_revision: null,
989
+ rebind_binding_hash: null,
990
+ rebind_binding_json: null,
991
+ rebind_attempt_id: null,
992
+ created_at: at,
993
+ updated_at: at,
994
+ revoked_at: null,
995
+ })
996
+ .execute();
997
+ }
998
+
999
+ async updateAuthBinding(
1000
+ hostKey: SessionID,
1001
+ fields: RuntimeAuthBindingUpdate,
1002
+ at: number,
1003
+ ): Promise<void> {
1004
+ await this.db
1005
+ .updateTable('runtime_auth_binding')
1006
+ .set({
1007
+ ...(fields.state === undefined ? {} : { state: fields.state }),
1008
+ ...(fields.impID === undefined ? {} : { imp_id: fields.impID }),
1009
+ ...(fields.impCreatedByAttempt === undefined
1010
+ ? {}
1011
+ : { imp_created_by_attempt: fields.impCreatedByAttempt ? 1 : 0 }),
1012
+ ...(fields.revision === undefined ? {} : { revision: fields.revision }),
1013
+ ...(fields.bindingHash === undefined ? {} : { binding_hash: fields.bindingHash }),
1014
+ ...(fields.bindingJSON === undefined ? {} : { binding_json: fields.bindingJSON }),
1015
+ ...(fields.attemptID === undefined ? {} : { attempt_id: fields.attemptID }),
1016
+ ...(fields.rebind === undefined
1017
+ ? {}
1018
+ : {
1019
+ rebind_revision: fields.rebind?.revision ?? null,
1020
+ rebind_binding_hash: fields.rebind?.bindingHash ?? null,
1021
+ rebind_binding_json: fields.rebind?.bindingJSON ?? null,
1022
+ rebind_attempt_id: fields.rebind?.attemptID ?? null,
1023
+ }),
1024
+ ...(fields.revokedAt === undefined ? {} : { revoked_at: fields.revokedAt }),
1025
+ updated_at: at,
1026
+ })
1027
+ .where('host_key', '=', hostKey)
1028
+ .execute();
1029
+ }
1030
+
1031
+ async findAuthBinding(hostKey: SessionID): Promise<RuntimeAuthBinding | null> {
1032
+ const row = await this.db
1033
+ .selectFrom('runtime_auth_binding')
1034
+ .selectAll()
1035
+ .where('host_key', '=', hostKey)
1036
+ .executeTakeFirst();
1037
+
1038
+ return row === undefined ? null : toRuntimeAuthBinding(row);
1039
+ }
1040
+
1041
+ // Writes one secret's grant row for a host, or moves the existing one to
1042
+ // the given phase, revision and attempt.
1043
+ async upsertAuthGrant(grant: Omit<RuntimeAuthGrant, 'updatedAt'>, at: number): Promise<void> {
1044
+ await this.db
1045
+ .insertInto('runtime_auth_grant')
1046
+ .values({
1047
+ host_key: grant.hostKey,
1048
+ secret: grant.secret,
1049
+ revision: grant.revision,
1050
+ attempt_id: grant.attemptID,
1051
+ preexisting: grant.preexisting ? 1 : 0,
1052
+ phase: grant.phase,
1053
+ updated_at: at,
1054
+ })
1055
+ .onConflict((oc) =>
1056
+ oc.columns(['host_key', 'secret']).doUpdateSet((eb) => ({
1057
+ revision: eb.ref('excluded.revision'),
1058
+ attempt_id: eb.ref('excluded.attempt_id'),
1059
+ preexisting: eb.ref('excluded.preexisting'),
1060
+ phase: eb.ref('excluded.phase'),
1061
+ updated_at: eb.ref('excluded.updated_at'),
1062
+ })),
1063
+ )
1064
+ .execute();
1065
+ }
1066
+
1067
+ // A host's grant rows, by secret name.
1068
+ async collectAuthGrants(hostKey: SessionID): Promise<RuntimeAuthGrant[]> {
1069
+ const rows = await this.db
1070
+ .selectFrom('runtime_auth_grant')
1071
+ .selectAll()
1072
+ .where('host_key', '=', hostKey)
1073
+ .orderBy('secret')
1074
+ .execute();
1075
+
1076
+ return rows.map((row) => ({
1077
+ hostKey: toSessionID(row.host_key),
1078
+ secret: row.secret,
1079
+ revision: row.revision,
1080
+ attemptID: row.attempt_id,
1081
+ preexisting: row.preexisting === 1,
1082
+ phase: row.phase,
1083
+ updatedAt: row.updated_at,
1084
+ }));
1085
+ }
1086
+
1087
+ // Drops a host's binding and every grant row it holds together, once
1088
+ // impd confirms the imp and its grants gone.
1089
+ async removeAuthBinding(hostKey: SessionID): Promise<void> {
1090
+ await this.db.transaction().execute(async (trx) => {
1091
+ await trx.deleteFrom('runtime_auth_grant').where('host_key', '=', hostKey).execute();
1092
+ await trx.deleteFrom('runtime_auth_binding').where('host_key', '=', hostKey).execute();
1093
+ });
1094
+ }
1095
+
1096
+ // Runs once as a daemon starts, before it serves a request: a grant still
1097
+ // granting belonged to a daemon that stopped before impd answered, so
1098
+ // whether impd holds it is uncertain until its grant list is read.
1099
+ async reconcileAuthBindings(at: number): Promise<void> {
1100
+ await this.db
1101
+ .updateTable('runtime_auth_grant')
1102
+ .set({ phase: 'uncertain', updated_at: at })
1103
+ .where('phase', '=', 'granting')
1104
+ .execute();
1105
+ }
1106
+
954
1107
  async stop(): Promise<void> {
955
1108
  await this.db.destroy();
956
1109
 
@@ -1334,3 +1487,36 @@ function toWorkspaceMaterialization(
1334
1487
  withheldEnv: parseWithheldEnv(row.withheld_env),
1335
1488
  };
1336
1489
  }
1490
+
1491
+ function toRuntimeAuthBinding(
1492
+ row: Readonly<StateStoreSchema['runtime_auth_binding']>,
1493
+ ): RuntimeAuthBinding {
1494
+ return {
1495
+ hostKey: toSessionID(row.host_key),
1496
+ target: row.target,
1497
+ targetIdentity: row.target_identity,
1498
+ impName: row.imp_name,
1499
+ impID: row.imp_id,
1500
+ revision: row.revision,
1501
+ bindingHash: row.binding_hash,
1502
+ bindingJSON: row.binding_json,
1503
+ state: row.state,
1504
+ attemptID: row.attempt_id,
1505
+ impCreatedByAttempt: row.imp_created_by_attempt === 1,
1506
+ rebind:
1507
+ row.rebind_revision === null ||
1508
+ row.rebind_binding_hash === null ||
1509
+ row.rebind_binding_json === null ||
1510
+ row.rebind_attempt_id === null
1511
+ ? null
1512
+ : {
1513
+ revision: row.rebind_revision,
1514
+ bindingHash: row.rebind_binding_hash,
1515
+ bindingJSON: row.rebind_binding_json,
1516
+ attemptID: row.rebind_attempt_id,
1517
+ },
1518
+ createdAt: row.created_at,
1519
+ updatedAt: row.updated_at,
1520
+ revokedAt: row.revoked_at,
1521
+ };
1522
+ }