mirror of
https://github.com/profullstack/logicsrc.git
synced 2026-10-03 05:07:10 +00:00
feat(credential-sharing): implement the Credential Sharing OpenSpec (M1-M3)
New @logicsrc/plugin-credential-sharing: a provider-neutral secret-sync engine with env/.env, Doppler, Railway, and GitHub Secrets adapters behind one CredentialProvider contract. - engine: inspect -> diff -> plan -> approve -> sync -> rollback -> audit/export - dry-run is the default for sync; --approve writes; destructive changes gated - fingerprint-based diffs (salted SHA-256); raw values never printed or stored in plans/runs/audit; rollback pre-image kept in a 0600 .logicsrc vault (gitignored) - github-secrets is write-only for values (sealed-box via libsodium), so it cannot be a sync source or value-restoring rollback target - CLI: real `logicsrc credentials <providers|inspect|diff|plan|approve|sync| rollback|audit|export>` (replaces the prior stub) - 4 JSON schemas registered in @logicsrc/validators - flip logicsrc.com/credential-sharing band from coming-soon to available - 37 tests pass; full env->env lifecycle verified; artifacts schema-validate Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
6e7f44612a
commit
cf73fe5af2
30 changed files with 1849 additions and 38 deletions
145
plugins/credential-sharing/src/engine.test.ts
Normal file
145
plugins/credential-sharing/src/engine.test.ts
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { CredentialEngine } from "./engine.js";
|
||||
import { createMemoryCredentialStore } from "./store.js";
|
||||
import { fingerprintValue } from "./fingerprint.js";
|
||||
import type { CredentialProvider, CredentialValueBag } from "./types.js";
|
||||
|
||||
/** In-memory provider backed by a mutable bag — stands in for env/doppler/railway. */
|
||||
function memoryProvider(id: string, initial: CredentialValueBag, opts: { readValues?: boolean } = {}): CredentialProvider & { store: CredentialValueBag } {
|
||||
const store: CredentialValueBag = { ...initial };
|
||||
const readValues = opts.readValues ?? true;
|
||||
return {
|
||||
id,
|
||||
name: id,
|
||||
description: id,
|
||||
capabilities: { readValues, readNames: true, write: true, delete: true, rollback: readValues, audit: false },
|
||||
authRequirements: [],
|
||||
status: "available",
|
||||
store,
|
||||
async inspect(endpoint) {
|
||||
return {
|
||||
provider: id,
|
||||
endpoint,
|
||||
valuesReadable: readValues,
|
||||
keys: Object.keys(store)
|
||||
.sort()
|
||||
.map((name) => ({ name, present: true, fingerprint: readValues ? fingerprintValue(store[name]) : undefined })),
|
||||
inspectedAt: new Date().toISOString()
|
||||
};
|
||||
},
|
||||
async readValues(_endpoint, keys) {
|
||||
return Object.fromEntries(keys.filter((k) => k in store).map((k) => [k, store[k]]));
|
||||
},
|
||||
async write({ upserts, deletes, dryRun }) {
|
||||
const results = [
|
||||
...Object.keys(upserts).map((key) => ({ key, applied: !dryRun })),
|
||||
...deletes.map((key) => ({ key, applied: !dryRun }))
|
||||
];
|
||||
if (!dryRun) {
|
||||
Object.assign(store, upserts);
|
||||
for (const key of deletes) delete store[key];
|
||||
}
|
||||
return results;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function makeEngine(providers: CredentialProvider[]) {
|
||||
let counter = 0;
|
||||
return new CredentialEngine({
|
||||
providers: new Map(providers.map((p) => [p.id, p])),
|
||||
store: createMemoryCredentialStore(),
|
||||
now: () => new Date("2026-06-27T00:00:00.000Z"),
|
||||
idFactory: (prefix) => `${prefix}_${++counter}`
|
||||
});
|
||||
}
|
||||
|
||||
describe("CredentialEngine diff + plan", () => {
|
||||
it("classifies add / update / unchanged by fingerprint", async () => {
|
||||
const source = memoryProvider("env", { A: "1", B: "2", SAME: "x" });
|
||||
const target = memoryProvider("railway", { B: "old", SAME: "x" });
|
||||
const engine = makeEngine([source, target]);
|
||||
|
||||
const diff = await engine.diffCredentialEndpoints({ provider: "env" }, { provider: "railway" });
|
||||
const byKey = Object.fromEntries(diff.entries.map((e) => [e.key, e.op]));
|
||||
expect(byKey).toEqual({ A: "add", B: "update", SAME: "unchanged" });
|
||||
});
|
||||
|
||||
it("never includes raw values in a plan", async () => {
|
||||
const source = memoryProvider("env", { SECRET: "super-secret-value" });
|
||||
const target = memoryProvider("railway", {});
|
||||
const engine = makeEngine([source, target]);
|
||||
|
||||
const plan = await engine.createCredentialSyncPlan({ from: { provider: "env" }, to: { provider: "railway" } });
|
||||
expect(JSON.stringify(plan)).not.toContain("super-secret-value");
|
||||
expect(plan.changes[0]).toMatchObject({ key: "SECRET", op: "add" });
|
||||
expect(plan.changes[0].sourceFingerprint).toBe(fingerprintValue("super-secret-value"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("CredentialEngine sync safety", () => {
|
||||
it("dry-run does not mutate the target", async () => {
|
||||
const source = memoryProvider("env", { A: "1" });
|
||||
const target = memoryProvider("railway", {});
|
||||
const engine = makeEngine([source, target]);
|
||||
|
||||
const plan = await engine.createCredentialSyncPlan({ from: { provider: "env" }, to: { provider: "railway" } });
|
||||
const run = await engine.runCredentialSync(plan.id, { dryRun: true });
|
||||
expect(run.status).toBe("dry_run");
|
||||
expect(target.store).toEqual({});
|
||||
});
|
||||
|
||||
it("requires approval before a destructive apply", async () => {
|
||||
const source = memoryProvider("env", { A: "new" });
|
||||
const target = memoryProvider("railway", { A: "old" });
|
||||
const engine = makeEngine([source, target]);
|
||||
|
||||
const plan = await engine.createCredentialSyncPlan({ from: { provider: "env" }, to: { provider: "railway" } });
|
||||
expect(plan.requiresApproval).toBe(true);
|
||||
await expect(engine.runCredentialSync(plan.id, { dryRun: false })).rejects.toThrow(/requires approval/);
|
||||
|
||||
const approval = engine.approveCredentialSync(plan.id);
|
||||
const run = await engine.runCredentialSync(plan.id, { dryRun: false, approval });
|
||||
expect(run.status).toBe("applied");
|
||||
expect(target.store.A).toBe("new");
|
||||
});
|
||||
|
||||
it("refuses a write-only provider as a sync source", async () => {
|
||||
const source = memoryProvider("github-secrets", { A: "1" }, { readValues: false });
|
||||
const target = memoryProvider("railway", {});
|
||||
const engine = makeEngine([source, target]);
|
||||
await expect(engine.createCredentialSyncPlan({ from: { provider: "github-secrets" }, to: { provider: "railway" } })).rejects.toThrow(/cannot read values/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("CredentialEngine rollback + audit", () => {
|
||||
it("rolls back updates to the pre-image and removes newly added keys", async () => {
|
||||
const source = memoryProvider("env", { A: "new", ADDED: "fresh" });
|
||||
const target = memoryProvider("railway", { A: "original" });
|
||||
const engine = makeEngine([source, target]);
|
||||
|
||||
const plan = await engine.createCredentialSyncPlan({ from: { provider: "env" }, to: { provider: "railway" } });
|
||||
const approval = engine.approveCredentialSync(plan.id);
|
||||
const run = await engine.runCredentialSync(plan.id, { dryRun: false, approval });
|
||||
expect(target.store).toEqual({ A: "new", ADDED: "fresh" });
|
||||
|
||||
const rollbackPlan = await engine.rollbackCredentialSync(run.id);
|
||||
expect(rollbackPlan.rollbackOfRunId).toBe(run.id);
|
||||
const rollbackApproval = engine.approveCredentialSync(rollbackPlan.id);
|
||||
await engine.runCredentialSync(rollbackPlan.id, { dryRun: false, approval: rollbackApproval });
|
||||
expect(target.store).toEqual({ A: "original" });
|
||||
});
|
||||
|
||||
it("writes audit events with fingerprints, never raw values", async () => {
|
||||
const source = memoryProvider("env", { TOKEN: "raw-token-abc" });
|
||||
const target = memoryProvider("railway", {});
|
||||
const engine = makeEngine([source, target]);
|
||||
|
||||
const plan = await engine.createCredentialSyncPlan({ from: { provider: "env" }, to: { provider: "railway" } });
|
||||
const run = await engine.runCredentialSync(plan.id, { dryRun: false });
|
||||
const audit = engine.exportCredentialAudit(run.id);
|
||||
expect(audit).toHaveLength(1);
|
||||
expect(audit[0]).toMatchObject({ key: "TOKEN", action: "credentials:add", fingerprint: fingerprintValue("raw-token-abc") });
|
||||
expect(JSON.stringify(audit)).not.toContain("raw-token-abc");
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue