Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions docs/credential-sharing.md
Original file line number Diff line number Diff line change
Expand Up @@ -183,15 +183,16 @@ re-wraps (seals) it to the new member's public key. The private key lives only i
logicsrc login

# Owner: create a team, push a local .env into an encrypted vault, invite people.
# A vault is addressed as <project> <env>, stored as the vault name project/env.
logicsrc teams create acme --name "Acme Inc"
logicsrc teams push acme prod --env .env # encrypt + upload
logicsrc teams push acme web prod --env .env # encrypt + upload
logicsrc teams invite acme teammate@example.com # emails an accept link

# Teammate: accept, then get granted, then pull + decrypt locally.
logicsrc login
logicsrc teams accept <token-from-email>
# …an existing member runs: logicsrc teams grant acme prod teammate@example.com
logicsrc teams pull acme prod --env .env # download + decrypt
# …an existing member runs: logicsrc teams grant acme web prod teammate@example.com
logicsrc teams pull acme web prod --env .env # download + decrypt

# Inspect / manage
logicsrc teams list
Expand Down
19 changes: 11 additions & 8 deletions packages/cli/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -601,29 +601,32 @@ teams
teams
.command("grant")
.argument("<slug>", "Team slug")
.argument("<vault>", "Vault name")
.argument("<project>", "Project name")
.argument("<env>", "Environment name (prod, staging, …)")
.argument("<email>", "Teammate email to grant vault access")
.option("--format <format>", "table, json, or markdown", "table")
.description("Grant a member decryption access to a vault (re-wraps the vault key to their key).")
.action((slug, vault, email, options) => teamsGrantAction(slug, vault, email, options.format as OutputFormat));
.action((slug, project, env, email, options) => teamsGrantAction(slug, project, env, email, options.format as OutputFormat));

teams
.command("push")
.argument("<slug>", "Team slug")
.argument("<vault>", "Vault name")
.argument("<project>", "Project name")
.argument("<env>", "Environment name (prod, staging, …)")
.option("--env <path>", "Source .env file", ".env")
.option("--format <format>", "table, json, or markdown", "table")
.description("Encrypt and push a local .env into a team vault.")
.action((slug, vault, options) => teamsPushAction(slug, vault, { env: options.env, format: options.format as OutputFormat }));
.description("Encrypt and push a local .env into a team vault (<project>/<env>).")
.action((slug, project, env, options) => teamsPushAction(slug, project, env, { env: options.env, format: options.format as OutputFormat }));

teams
.command("pull")
.argument("<slug>", "Team slug")
.argument("<vault>", "Vault name")
.argument("<project>", "Project name")
.argument("<env>", "Environment name (prod, staging, …)")
.option("--env <path>", "Destination .env file", ".env")
.option("--format <format>", "table, json, or markdown", "table")
.description("Pull a team vault and decrypt it into a local .env.")
.action((slug, vault, options) => teamsPullAction(slug, vault, { env: options.env, format: options.format as OutputFormat }));
.description("Pull a team vault (<project>/<env>) and decrypt it into a local .env.")
.action((slug, project, env, options) => teamsPullAction(slug, project, env, { env: options.env, format: options.format as OutputFormat }));

const accounts = program.command("accounts").description("Manage connected social and email accounts.");

Expand Down
50 changes: 50 additions & 0 deletions packages/cli/src/teams.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { describe, expect, it } from "vitest";
import { splitVaultName, vaultName } from "./teams.js";

// A vault is addressed as <project> <env> on the command line and stored as a
// single `project/env` name server-side. The join is the only thing keeping
// those two halves apart, so it has to reject anything that would make the
// name ambiguous — a wrong split would point a push at the wrong vault.

describe("vaultName", () => {
it("joins project and env with a slash", () => {
expect(vaultName("web", "prod")).toBe("web/prod");
});

it("keeps distinct envs of one project apart", () => {
expect(vaultName("web", "staging")).not.toBe(vaultName("web", "prod"));
});

it("keeps distinct projects in one env apart", () => {
expect(vaultName("api", "prod")).not.toBe(vaultName("web", "prod"));
});

it("rejects a slash in either half", () => {
expect(() => vaultName("web/api", "prod")).toThrow(/cannot contain/);
expect(() => vaultName("web", "prod/eu")).toThrow(/cannot contain/);
});

it("rejects empty or blank halves", () => {
expect(() => vaultName("", "prod")).toThrow(/Missing project/);
expect(() => vaultName("web", "")).toThrow(/Missing env/);
expect(() => vaultName(" ", "prod")).toThrow(/Missing project/);
});
});

describe("splitVaultName", () => {
it("round-trips a name built by vaultName", () => {
expect(splitVaultName(vaultName("web", "prod"))).toEqual({ project: "web", env: "prod" });
});

it("returns null for legacy single-word names", () => {
// Vaults created before the split are still listable; they just don't
// decompose, so `teams vaults` shows the raw name instead of guessing.
expect(splitVaultName("prod")).toBeNull();
});

it("returns null rather than guessing at an ambiguous name", () => {
expect(splitVaultName("a/b/c")).toBeNull();
expect(splitVaultName("/prod")).toBeNull();
expect(splitVaultName("web/")).toBeNull();
});
});
77 changes: 65 additions & 12 deletions packages/cli/src/teams.ts
Original file line number Diff line number Diff line change
Expand Up @@ -176,11 +176,48 @@ class DeviceFlowUnsupported extends Error {
constructor() { super("device flow not supported by this server"); }
}

// A vault is addressed as <project>/<env>, so one team can hold web/prod,
// web/staging and api/prod side by side. The split lives entirely in the CLI —
// the server still stores a single opaque vault name — so this join and
// splitVaultName() below are the only places that know about the convention.
// Neither half may contain a slash, which keeps the join unambiguous and makes
// splitVaultName a true inverse.
export function vaultName(project: string, env: string): string {
const parts: ReadonlyArray<readonly [string, string]> = [
["project", project],
["env", env]
];
for (const [label, value] of parts) {
if (!value || !value.trim()) {
throw new Error(`Missing ${label}. Usage: logicsrc teams push <team> <project> <env>`);
}
if (value.includes("/")) {
throw new Error(`The ${label} "${value}" cannot contain "/" — it separates project from env in a vault name.`);
}
}
return `${project}/${env}`;
}

/** Inverse of vaultName; null for names that predate the convention. */
export function splitVaultName(name: string): { project: string; env: string } | null {
const slash = name.indexOf("/");
if (slash <= 0 || slash === name.length - 1) return null;
const env = name.slice(slash + 1);
if (env.includes("/")) return null;
return { project: name.slice(0, slash), env };
}

async function resolveVaultId(client: TeamClient, slug: string, vault: string): Promise<string> {
const { vaults } = await client.listVaults(slug);
const found = vaults.find((v) => v.name === vault);
if (!found) throw new Error(`Vault "${vault}" not found in team "${slug}". Create it by pushing to it.`);
return found.id;
if (found) return found.id;
// Vault names were a single word before they became <project>/<env>, so a
// team can still hold legacy rows. Name them instead of silently retargeting
// — picking a different vault than the one asked for would mean pushing
// secrets somewhere the caller didn't say.
const known = vaults.map((v) => v.name);
const hint = known.length ? ` Existing vaults: ${known.join(", ")}.` : "";
throw new Error(`Vault "${vault}" not found in team "${slug}". Create it by pushing to it.${hint}`);
}

export async function loginAction(options: { apiUrl?: string; token?: string; device?: boolean; web?: boolean }): Promise<void> {
Expand Down Expand Up @@ -288,13 +325,25 @@ export async function teamsVaultsAction(slug: string, format: OutputFormat): Pro
const { client } = authedClient();
const { vaults } = await client.listVaults(slug);
print(
vaults.length ? vaults.map((v) => ({ vault: v.name, secrets: v.secretCount, youHaveAccess: v.hasAccess })) : [{ note: "No vaults yet. Push to create one: logicsrc teams push <team> <vault>" }],
vaults.length
? vaults.map((v) => {
const parts = splitVaultName(v.name);
return {
vault: v.name,
project: parts?.project ?? v.name,
env: parts?.env ?? "—",
secrets: v.secretCount,
youHaveAccess: v.hasAccess
};
})
: [{ note: "No vaults yet. Push to create one: logicsrc teams push <team> <project> <env>" }],
format
);
}

export async function teamsGrantAction(slug: string, vault: string, email: string, format: OutputFormat): Promise<void> {
export async function teamsGrantAction(slug: string, project: string, env: string, email: string, format: OutputFormat): Promise<void> {
const { client, identity } = authedClient();
const vault = vaultName(project, env);
const vaultId = await resolveVaultId(client, slug, vault);

// Unwrap the vault DEK with our own key, then re-wrap it to the target member.
Expand All @@ -314,44 +363,48 @@ export async function teamsGrantAction(slug: string, vault: string, email: strin
if (!target.publicKey) throw new Error(`${email} has not registered a key yet. Ask them to run: logicsrc login --email ${email}`);

await client.putGrant(vaultId, email, await wrapVaultKey(dek, target.publicKey));
console.error(`Granted ${email} access to ${slug}/${vault}. They can now: logicsrc teams pull ${slug} ${vault}`);
print({ granted: email, team: slug, vault }, format);
console.error(`Granted ${email} access to ${slug}/${vault}. They can now: logicsrc teams pull ${slug} ${project} ${env}`);
print({ granted: email, team: slug, project, env, vault }, format);
}

function teamEndpoint(slug: string, vault: string): CredentialEndpoint {
return { provider: "team", project: slug, config: vault };
}

export async function teamsPushAction(slug: string, vault: string, options: { env: string; format: OutputFormat }): Promise<void> {
// Note the two different "env"s: `envName` is the environment half of the vault
// address (prod, staging), while `options.env` is the local .env file path.
export async function teamsPushAction(slug: string, project: string, envName: string, options: { env: string; format: OutputFormat }): Promise<void> {
requireAuth();
const vault = vaultName(project, envName);
const engine = createCredentialEngine();
const from: CredentialEndpoint = { provider: "env", path: options.env };
const plan = await engine.createCredentialSyncPlan({ from, to: teamEndpoint(slug, vault) });
if (plan.changes.length === 0) {
console.error(`${slug}/${vault} is already up to date with ${options.env}.`);
print({ team: slug, vault, changes: 0 }, options.format);
print({ team: slug, project, env: envName, vault, changes: 0 }, options.format);
return;
}
const approval = engine.approveCredentialSync(plan.id);
const run = await engine.runCredentialSync(plan.id, { dryRun: false, approval });
const applied = run.results.filter((r) => r.applied).length;
console.error(`Pushed ${applied} secret(s) from ${options.env} to ${slug}/${vault} (end-to-end encrypted).`);
print({ team: slug, vault, applied, keys: run.results.map((r) => ({ key: r.key, op: r.op, applied: r.applied })) }, options.format);
print({ team: slug, project, env: envName, vault, applied, keys: run.results.map((r) => ({ key: r.key, op: r.op, applied: r.applied })) }, options.format);
}

export async function teamsPullAction(slug: string, vault: string, options: { env: string; format: OutputFormat }): Promise<void> {
export async function teamsPullAction(slug: string, project: string, envName: string, options: { env: string; format: OutputFormat }): Promise<void> {
requireAuth();
const vault = vaultName(project, envName);
const engine = createCredentialEngine();
const to: CredentialEndpoint = { provider: "env", path: options.env };
const plan = await engine.createCredentialSyncPlan({ from: teamEndpoint(slug, vault), to });
if (plan.changes.length === 0) {
console.error(`${options.env} is already up to date with ${slug}/${vault}.`);
print({ team: slug, vault, changes: 0 }, options.format);
print({ team: slug, project, env: envName, vault, changes: 0 }, options.format);
return;
}
const approval = engine.approveCredentialSync(plan.id);
const run = await engine.runCredentialSync(plan.id, { dryRun: false, approval });
const applied = run.results.filter((r) => r.applied).length;
console.error(`Pulled ${applied} secret(s) from ${slug}/${vault} into ${options.env}.`);
print({ team: slug, vault, applied, keys: run.results.map((r) => ({ key: r.key, op: r.op, applied: r.applied })) }, options.format);
print({ team: slug, project, env: envName, vault, applied, keys: run.results.map((r) => ({ key: r.key, op: r.op, applied: r.applied })) }, options.format);
}
Loading