Skip to content

Commit 6197b7a

Browse files
committed
feat(deployment): open hidden variables sealed under a team's data key
A stored token whose header names an organization instead of a member opens under the data key and binding of the organization its row is filed in. Patching, redeploying, inheriting and sending the manifest of a team deployment read it that way, including after the member who filed the row deleted their account. A user re-key leaves such a row to the team and counts it apart. Nothing seals under a team's key yet, so every stored token still opens as before. Part of CON-1213
1 parent 510ddc5 commit 6197b7a

18 files changed

Lines changed: 318 additions & 81 deletions

‎apps/api/scripts/rehearseDeploymentSecrets.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -251,7 +251,9 @@ async function inspectDeployment(userId: string, row: DeploymentStoredSecretsOfU
251251
const kid = kidOf(row.sealedSecrets);
252252

253253
try {
254-
const secrets = await container.resolve(SdlSecretsService).openStored({ userId, dseq: row.dseq, sealedSecrets: row.sealedSecrets });
254+
const secrets = await container
255+
.resolve(SdlSecretsService)
256+
.openStored({ userId, organizationId: row.organizationId, dseq: row.dseq, sealedSecrets: row.sealedSecrets });
255257

256258
return { dseq: row.dseq, kid, opens: true, secretCount: Object.keys(secrets).length };
257259
} catch (error) {

‎apps/api/src/deployment/repositories/deployment-setting/deployment-setting.repository.ts‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -145,7 +145,7 @@ export type DeploymentStoredSecrets = {
145145
};
146146

147147
/** A user's stored secrets token with the deployment it is bound to, which re-sealing it under another key has to name. */
148-
export type DeploymentStoredSecretsOfUser = DeploymentStoredSecrets & { dseq: string };
148+
export type DeploymentStoredSecretsOfUser = DeploymentStoredSecrets & Pick<DeploymentSettingsDbOutput, "dseq" | "organizationId">;
149149

150150
export type StoredLeaseGpus = { readings: LeaseGpuReading[]; offers: LeaseGpuOffer[] };
151151

@@ -840,7 +840,13 @@ export class DeploymentSettingRepository extends OrgScopedRepository<Table, Depl
840840

841841
while (true) {
842842
const batch = await this.pg
843-
.select({ id: this.table.id, dseq: this.table.dseq, sealedSecrets: this.table.sealedSecrets, updatedAt: this.table.updatedAt })
843+
.select({
844+
id: this.table.id,
845+
dseq: this.table.dseq,
846+
organizationId: this.table.organizationId,
847+
sealedSecrets: this.table.sealedSecrets,
848+
updatedAt: this.table.updatedAt
849+
})
844850
.from(this.table)
845851
.where(this.whereAccessibleBy(and(eq(this.table.userId, userId), isNotNull(this.table.sealedSecrets), ...(cursor ? [gt(this.table.id, cursor)] : []))))
846852
.orderBy(asc(this.table.id))

‎apps/api/src/deployment/services/deployment-writer/deployment-writer.service.spec.ts‎

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3310,7 +3310,12 @@ describe(DeploymentWriterService.name, () => {
33103310

33113311
await service.patchByUserIdAndDseq("user-1", "1234", { services: { web: { image: "nginx:1.27" } } }, ability);
33123312

3313-
expect(sdlSecretsService.openStored).toHaveBeenCalledWith({ userId: "user-1", dseq: "1234", sealedSecrets: STORED_TOKEN });
3313+
expect(sdlSecretsService.openStored).toHaveBeenCalledWith({
3314+
userId: "user-1",
3315+
organizationId: "personal-organization-1",
3316+
dseq: "1234",
3317+
sealedSecrets: STORED_TOKEN
3318+
});
33143319
});
33153320

33163321
it("opens the client's seal against the sdl the console stored, which is what it was sealed to", async () => {
@@ -4404,7 +4409,12 @@ describe(DeploymentWriterService.name, () => {
44044409

44054410
await service.applySavedPatch(savedPatch, ability);
44064411

4407-
expect(sdlSecretsService.openStored).toHaveBeenCalledWith({ userId: "user-1", dseq: "100", sealedSecrets: "stored.token.aaa.bbb.ccc" });
4412+
expect(sdlSecretsService.openStored).toHaveBeenCalledWith({
4413+
userId: "user-1",
4414+
organizationId: "organization-of-the-row",
4415+
dseq: "100",
4416+
sealedSecrets: "stored.token.aaa.bbb.ccc"
4417+
});
44084418
});
44094419

44104420
it("rebuilds the manifest from the saved sdl and the values its token holds", async () => {
@@ -4603,7 +4613,12 @@ describe(DeploymentWriterService.name, () => {
46034613

46044614
expect(signerService.filedUserIdOf).toHaveBeenCalledWith(organizationWallet, "user-1", "100");
46054615
expect(scopedSettingRepository.findOneBy).toHaveBeenCalledWith({ userId: "filer-1", dseq: "100" });
4606-
expect(sdlSecretsService.openStored).toHaveBeenCalledWith({ userId: "filer-1", dseq: "100", sealedSecrets: "stored.token.aaa.bbb.ccc" });
4616+
expect(sdlSecretsService.openStored).toHaveBeenCalledWith({
4617+
userId: "filer-1",
4618+
organizationId: "organization-of-the-row",
4619+
dseq: "100",
4620+
sealedSecrets: "stored.token.aaa.bbb.ccc"
4621+
});
46074622
});
46084623

46094624
it("updates the chain and pushes the manifest with the organization's wallet, for the member who saved the patch", async () => {
@@ -4951,6 +4966,7 @@ describe(DeploymentWriterService.name, () => {
49514966
sdl: input?.storedSdl ?? SDL_OF_A_REDEPLOY,
49524967
runtimeLimitHours: input?.storedRuntimeLimitHours ?? null,
49534968
sealedSecrets: "stored.token.aaa.bbb.ccc",
4969+
organizationId: "organization-of-the-row",
49544970
manifestVersion: input?.storedManifestVersion ?? savedVersion
49554971
})
49564972
);

‎apps/api/src/deployment/services/deployment-writer/deployment-writer.service.ts‎

Lines changed: 21 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,15 @@ type PreparedPatch = {
200200
assertMayStillSign?: () => Promise<void>;
201201
};
202202

203+
type StoredDefinition = {
204+
sdl: string;
205+
sealedSecrets: string | null;
206+
manifestVersion?: string;
207+
runtimeLimitHours?: number;
208+
/** Absent for a deployment filed before organizations, whose row only its user reaches. */
209+
organizationId?: string;
210+
};
211+
203212
type PatchWrite = {
204213
definition: Parameters<DeploymentSettingRepository["replaceDefinitionIfVersionMatches"]>[0];
205214
counts: { patchedServiceCount: number; secretCount: number; guarded: boolean };
@@ -981,7 +990,9 @@ export class DeploymentWriterService {
981990

982991
const [supplied, held] = await Promise.all([
983992
input.sealedSecrets ? this.sdlSecretsService.receiveForMerge({ rawSdl: stored.sdl, sealedSecrets: input.sealedSecrets }) : {},
984-
stored.sealedSecrets ? this.sdlSecretsService.openStored({ userId: filedUserId, dseq, sealedSecrets: stored.sealedSecrets }) : {}
993+
stored.sealedSecrets
994+
? this.sdlSecretsService.openStored({ userId: filedUserId, organizationId: stored.organizationId ?? null, dseq, sealedSecrets: stored.sealedSecrets })
995+
: {}
985996
]);
986997
const merged = this.#mergeAndPrune({ held, supplied, derived }, document);
987998

@@ -1027,11 +1038,7 @@ export class DeploymentWriterService {
10271038
}
10281039

10291040
/** Read from the row even right after the save, so a patch a newer one replaced is never applied on chain, however late it runs. */
1030-
async #findDefinitionSavedAs(
1031-
saved: SavedPatch,
1032-
filedUserId: string,
1033-
ability: AnyAbility
1034-
): Promise<{ sdl: string; sealedSecrets: string | null } | undefined> {
1041+
async #findDefinitionSavedAs(saved: SavedPatch, filedUserId: string, ability: AnyAbility): Promise<StoredDefinition | undefined> {
10351042
const definition = await this.#findStoredDefinition({ userId: filedUserId, dseq: saved.dseq }, ability, NOT_PATCHABLE_MESSAGE);
10361043

10371044
if (definition.manifestVersion === saved.manifestVersion) return definition;
@@ -1044,10 +1051,15 @@ export class DeploymentWriterService {
10441051
/** Resolved without trial limits, which refuse a patch when it is saved and never change the manifest it hashes. */
10451052
async #prepareSavedDefinition(
10461053
{ saved, wallet, filedUserId }: { saved: SavedPatch; wallet: WalletInitialized; filedUserId: string },
1047-
definition: { sdl: string; sealedSecrets: string | null }
1054+
definition: StoredDefinition
10481055
): Promise<PreparedPatch> {
10491056
const secrets = definition.sealedSecrets
1050-
? await this.sdlSecretsService.openStored({ userId: filedUserId, dseq: saved.dseq, sealedSecrets: definition.sealedSecrets })
1057+
? await this.sdlSecretsService.openStored({
1058+
userId: filedUserId,
1059+
organizationId: definition.organizationId ?? null,
1060+
dseq: saved.dseq,
1061+
sealedSecrets: definition.sealedSecrets
1062+
})
10511063
: {};
10521064
const resolved = await this.#resolveSdl(definition.sdl, { secrets });
10531065
const deployment = await this.deploymentReaderService.findByWalletAndDseqWithoutProviderStatus(wallet, saved.dseq);
@@ -1173,11 +1185,7 @@ export class DeploymentWriterService {
11731185
return { ...deployment, name: persistedName };
11741186
}
11751187

1176-
async #findStoredDefinition(
1177-
key: { userId: string; dseq: string },
1178-
ability: AnyAbility,
1179-
notFoundMessage: string
1180-
): Promise<{ sdl: string; sealedSecrets: string | null; manifestVersion?: string; runtimeLimitHours?: number; organizationId?: string }> {
1188+
async #findStoredDefinition(key: { userId: string; dseq: string }, ability: AnyAbility, notFoundMessage: string): Promise<StoredDefinition> {
11811189
const setting = await this.deploymentSettingRepository.accessibleBy(ability, "read").findOneBy(key);
11821190

11831191
if (!setting?.sdl) {

‎apps/api/src/deployment/services/lease-manifest/lease-manifest.service.spec.ts‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import { mockConfigService } from "@test/mocks/config-service.mock";
2323

2424
const USER_ID = "3f1b6d4e-0000-4000-8000-000000000001";
2525
const DSEQ = "1420000000001";
26+
const ORGANIZATION_ID = "5c2a7e1f-0000-4000-8000-000000000002";
2627

2728
function sdlWith(env: string[], options: { credentials?: Record<string, string>; gpuModel?: string } = {}) {
2829
const credentialsBlock = options.credentials
@@ -184,7 +185,7 @@ describe(LeaseManifestService.name, () => {
184185

185186
await service.deriveFor({ dseq: DSEQ, userId: USER_ID });
186187

187-
expect(sdlSecretsService.openStored).toHaveBeenCalledWith({ userId: USER_ID, dseq: DSEQ, sealedSecrets: "sealed" });
188+
expect(sdlSecretsService.openStored).toHaveBeenCalledWith({ userId: USER_ID, organizationId: ORGANIZATION_ID, dseq: DSEQ, sealedSecrets: "sealed" });
188189
});
189190

190191
it("logs what it derived and no part of what it resolved", async () => {
@@ -400,7 +401,9 @@ describe(LeaseManifestService.name, () => {
400401

401402
deploymentSettingRepository.accessibleBy.mockReturnValue(scopedRepository);
402403
scopedRepository.findOneBy.mockResolvedValue(
403-
input.definition ? mock<DeploymentSettingsOutput>({ sdl: input.definition.sdl, sealedSecrets: input.definition.sealedSecrets }) : undefined
404+
input.definition
405+
? mock<DeploymentSettingsOutput>({ sdl: input.definition.sdl, sealedSecrets: input.definition.sealedSecrets, organizationId: ORGANIZATION_ID })
406+
: undefined
404407
);
405408

406409
const createLogger: CreateLogger = () => logger;

‎apps/api/src/deployment/services/lease-manifest/lease-manifest.service.ts‎

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import { SdlService } from "@src/deployment/services/sdl/sdl.service";
1010
import { SdlReferenceService } from "@src/deployment/services/sdl-reference/sdl-reference.service";
1111
import { SdlSecretsService } from "@src/deployment/services/sdl-secrets/sdl-secrets.service";
1212

13-
type StoredDefinition = { sdl: string; sealedSecrets: string | null };
13+
type StoredDefinition = { sdl: string; sealedSecrets: string | null; organizationId: string | null };
1414

1515
/** Why a deployment has no manifest of its own, which is what the residual volume of client-supplied ones is measured by. */
1616
type StoredDefinitionMiss = "nothing-recorded" | "unresolvable";
@@ -48,7 +48,8 @@ export class LeaseManifestService {
4848
return this.#fallBack({ userId, dseq, reason: "nothing-recorded" });
4949
}
5050

51-
const secrets = definition.sealedSecrets ? await this.#openStored(definition.sealedSecrets, { userId, dseq }) : {};
51+
const { sealedSecrets, organizationId } = definition;
52+
const secrets = sealedSecrets ? await this.#openStored({ sealedSecrets, organizationId }, { userId, dseq }) : {};
5253

5354
const resolved = await this.sdlService.generateResolvedManifest({ sdl: definition.sdl, secrets });
5455

@@ -65,7 +66,7 @@ export class LeaseManifestService {
6566
async #findDefinition(key: { userId: string; dseq: string }): Promise<StoredDefinition | null> {
6667
const setting = await this.deploymentSettingRepository.accessibleBy(this.authService.ability, "read").findOneBy(key);
6768

68-
if (setting?.sdl) return { sdl: setting.sdl, sealedSecrets: setting.sealedSecrets };
69+
if (setting?.sdl) return { sdl: setting.sdl, sealedSecrets: setting.sealedSecrets, organizationId: setting.organizationId };
6970

7071
if (setting?.sealedSecrets) throw this.#refuse(key);
7172

@@ -95,9 +96,9 @@ export class LeaseManifestService {
9596
}
9697

9798
/** Keeps the cause's own error, so a key service that is merely unreachable still answers 503 rather than reading as a definition that cannot be derived. */
98-
async #openStored(sealedSecrets: string, key: { userId: string; dseq: string }) {
99+
async #openStored(stored: { sealedSecrets: string; organizationId: string | null }, key: { userId: string; dseq: string }) {
99100
try {
100-
return await this.sdlSecretsService.openStored({ ...key, sealedSecrets });
101+
return await this.sdlSecretsService.openStored({ ...key, ...stored });
101102
} catch (error) {
102103
throw isRetryable(error) ? this.#reportUnreachable(key, error) : this.#refuse(key, error);
103104
}

‎apps/api/src/deployment/services/sdl-secrets-inheritance/sdl-secrets-inheritance.service.spec.ts‎

Lines changed: 33 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ describe(SdlSecretsInheritanceService.name, () => {
3030

3131
await service.open(REQUEST);
3232

33-
expect(sdlSecretsService.openStored).toHaveBeenCalledWith({ ...SOURCE, sealedSecrets: TOKEN });
33+
expect(sdlSecretsService.openStored).toHaveBeenCalledWith({ ...SOURCE, organizationId: null, sealedSecrets: TOKEN });
3434
});
3535

3636
it("finds the source through the wallet that owns it, only where the caller may change it", async () => {
@@ -43,11 +43,13 @@ describe(SdlSecretsInheritanceService.name, () => {
4343
});
4444

4545
it("opens an organization's source under the member it was filed by", async () => {
46-
const { service, sdlSecretsService } = setup({ setting: mock<DeploymentSettingsOutput>({ sealedSecrets: TOKEN, userId: "filer-1" }) });
46+
const { service, sdlSecretsService } = setup({
47+
setting: mock<DeploymentSettingsOutput>({ sealedSecrets: TOKEN, userId: "filer-1", organizationId: "organization-1" })
48+
});
4749

4850
await service.open({ wallet: { userId: null, organizationId: "organization-1" }, dseq: SOURCE.dseq });
4951

50-
expect(sdlSecretsService.openStored).toHaveBeenCalledWith({ userId: "filer-1", dseq: SOURCE.dseq, sealedSecrets: TOKEN });
52+
expect(sdlSecretsService.openStored).toHaveBeenCalledWith({ userId: "filer-1", organizationId: "organization-1", dseq: SOURCE.dseq, sealedSecrets: TOKEN });
5153
});
5254

5355
it("inherits from a closed deployment, because closing a deployment keeps its secrets", async () => {
@@ -92,6 +94,26 @@ describe(SdlSecretsInheritanceService.name, () => {
9294
expect(sdlSecretsService.openStored).not.toHaveBeenCalled();
9395
});
9496

97+
it("answers not found for a team's source whose filer has deleted their account and that holds no token", async () => {
98+
const { service } = setup({ setting: mock<DeploymentSettingsOutput>({ sealedSecrets: null, userId: null }) });
99+
100+
await expect(service.open({ wallet: { userId: null, organizationId: "organization-1" }, dseq: SOURCE.dseq })).rejects.toMatchObject({ status: 404 });
101+
});
102+
});
103+
104+
describe("a team's source whose token is sealed under the team's key", () => {
105+
it("opens it after its filer has deleted their account, since the team's key outlives them", async () => {
106+
const carried = { TOKEN: faker.string.alphanumeric(32) };
107+
const sealedSecrets = tokenSealedForTeam("organization-1");
108+
const { service, sdlSecretsService } = setup({
109+
setting: mock<DeploymentSettingsOutput>({ sealedSecrets, userId: null, organizationId: "organization-1" }),
110+
opened: carried
111+
});
112+
113+
await expect(service.open({ wallet: { userId: null, organizationId: "organization-1" }, dseq: SOURCE.dseq })).resolves.toEqual(carried);
114+
expect(sdlSecretsService.openStored).toHaveBeenCalledWith({ userId: null, organizationId: "organization-1", dseq: SOURCE.dseq, sealedSecrets });
115+
});
116+
95117
it("says nothing about whether the deployment exists", async () => {
96118
const { service } = setup({ setting: undefined });
97119

@@ -241,12 +263,19 @@ describe(SdlSecretsInheritanceService.name, () => {
241263
expect(JSON.stringify(vi.mocked(logger.info).mock.calls)).not.toContain(value);
242264
});
243265

266+
function tokenSealedForTeam(organizationId: string) {
267+
const header = Buffer.from(JSON.stringify({ org: organizationId, dseq: SOURCE.dseq, alg: "dir", enc: "A256GCM", kid: "team-key" })).toString("base64url");
268+
269+
return `${header}..iv.ciphertext.tag`;
270+
}
271+
244272
function unreadableTokenError() {
245273
return createError(500, SECRET_UNREADABLE_ERROR_MESSAGE, { errorCode: SECRET_UNREADABLE_ERROR_CODE });
246274
}
247275

248276
function setup(input?: { setting?: DeploymentSettingsOutput; opened?: SdlSecrets; openStoredError?: Error }) {
249-
const setting = "setting" in (input ?? {}) ? input!.setting : mock<DeploymentSettingsOutput>({ sealedSecrets: TOKEN, userId: SOURCE.userId });
277+
const setting =
278+
"setting" in (input ?? {}) ? input!.setting : mock<DeploymentSettingsOutput>({ sealedSecrets: TOKEN, userId: SOURCE.userId, organizationId: null });
250279

251280
const scoped = mock<DeploymentSettingRepository>();
252281
scoped.findOneOfWallet.mockResolvedValue(setting);

0 commit comments

Comments
 (0)