mirror of
https://github.com/profullstack/logicsrc.git
synced 2026-08-14 23:07:29 +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
21
plugins/credential-sharing/package.json
Normal file
21
plugins/credential-sharing/package.json
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
{
|
||||
"name": "@logicsrc/plugin-credential-sharing",
|
||||
"version": "0.1.0",
|
||||
"description": "LogicSRC Credential Sharing OpenSpec plugin: portable, auditable secret sync across .env, Doppler, Railway, and GitHub Secrets.",
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"test": "vitest run src --passWithNoTests"
|
||||
},
|
||||
"dependencies": {
|
||||
"@logicsrc/account-core": "file:../../packages/account-core",
|
||||
"@logicsrc/plugin-core": "file:../../packages/plugin-core",
|
||||
"libsodium-wrappers": "^0.7.15"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/libsodium-wrappers": "^0.7.14",
|
||||
"vitest": "^4.0.8"
|
||||
}
|
||||
}
|
||||
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");
|
||||
});
|
||||
});
|
||||
358
plugins/credential-sharing/src/engine.ts
Normal file
358
plugins/credential-sharing/src/engine.ts
Normal file
|
|
@ -0,0 +1,358 @@
|
|||
import type { LogicSrcPrincipal, LogicSrcPolicyDecision } from "@logicsrc/account-core";
|
||||
import { fingerprintValue, fingerprintsEqual } from "./fingerprint.js";
|
||||
import { createFileCredentialStore, type CredentialStore } from "./store.js";
|
||||
import { credentialProviderRegistry } from "./providers/index.js";
|
||||
import type {
|
||||
CredentialEndpoint,
|
||||
CredentialProvider,
|
||||
CredentialSnapshot,
|
||||
CredentialDiff,
|
||||
CredentialDiffEntry,
|
||||
CredentialPolicy,
|
||||
CredentialSyncPlan,
|
||||
CredentialApproval,
|
||||
CredentialSyncRun,
|
||||
CredentialKeyResult,
|
||||
CredentialAuditEvent,
|
||||
CredentialValueBag,
|
||||
CredentialProviderManifest
|
||||
} from "./types.js";
|
||||
|
||||
export interface CredentialEngineOptions {
|
||||
providers?: Map<string, CredentialProvider>;
|
||||
store?: CredentialStore;
|
||||
principal?: LogicSrcPrincipal;
|
||||
now?: () => Date;
|
||||
idFactory?: (prefix: string) => string;
|
||||
}
|
||||
|
||||
export const DEFAULT_CREDENTIAL_POLICY: CredentialPolicy = {
|
||||
redactValues: true,
|
||||
requireApprovalForDestructive: true
|
||||
};
|
||||
|
||||
const DEFAULT_PRINCIPAL: LogicSrcPrincipal = { type: "user", id: "local" };
|
||||
|
||||
function defaultId(prefix: string): string {
|
||||
return `${prefix}_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
|
||||
}
|
||||
|
||||
export class CredentialEngine {
|
||||
private readonly providers: Map<string, CredentialProvider>;
|
||||
private readonly store: CredentialStore;
|
||||
private readonly principal: LogicSrcPrincipal;
|
||||
private readonly now: () => Date;
|
||||
private readonly id: (prefix: string) => string;
|
||||
|
||||
constructor(options: CredentialEngineOptions = {}) {
|
||||
this.providers = options.providers ?? credentialProviderRegistry;
|
||||
this.store = options.store ?? createFileCredentialStore();
|
||||
this.principal = options.principal ?? DEFAULT_PRINCIPAL;
|
||||
this.now = options.now ?? (() => new Date());
|
||||
this.id = options.idFactory ?? defaultId;
|
||||
}
|
||||
|
||||
private iso(): string {
|
||||
return this.now().toISOString();
|
||||
}
|
||||
|
||||
private requireProvider(id: string): CredentialProvider {
|
||||
const provider = this.providers.get(id);
|
||||
if (!provider) {
|
||||
throw new Error(`Unknown credential provider: ${id}. Run "logicsrc credentials providers" to list available adapters.`);
|
||||
}
|
||||
return provider;
|
||||
}
|
||||
|
||||
listCredentialProviders(): CredentialProviderManifest[] {
|
||||
return [...this.providers.values()].map(({ id, name, description, capabilities, authRequirements, status }) => ({
|
||||
id,
|
||||
name,
|
||||
description,
|
||||
capabilities,
|
||||
authRequirements,
|
||||
status
|
||||
}));
|
||||
}
|
||||
|
||||
async inspectCredentialSource(endpoint: CredentialEndpoint): Promise<CredentialSnapshot> {
|
||||
return this.requireProvider(endpoint.provider).inspect(endpoint);
|
||||
}
|
||||
|
||||
/** Compare a source against a target without moving any secret. */
|
||||
async diffCredentialEndpoints(from: CredentialEndpoint, to: CredentialEndpoint): Promise<CredentialDiff> {
|
||||
const source = this.requireProvider(from.provider);
|
||||
const target = this.requireProvider(to.provider);
|
||||
const [sourceSnapshot, targetSnapshot] = await Promise.all([source.inspect(from), target.inspect(to)]);
|
||||
|
||||
const targetByName = new Map(targetSnapshot.keys.map((key) => [key.name, key]));
|
||||
const entries: CredentialDiffEntry[] = [];
|
||||
|
||||
for (const sourceKey of sourceSnapshot.keys) {
|
||||
const targetKey = targetByName.get(sourceKey.name);
|
||||
if (!targetKey) {
|
||||
entries.push({ key: sourceKey.name, op: "add", sourceFingerprint: sourceKey.fingerprint, destructive: false });
|
||||
continue;
|
||||
}
|
||||
const comparable = sourceKey.fingerprint !== undefined && targetKey.fingerprint !== undefined;
|
||||
if (comparable && fingerprintsEqual(sourceKey.fingerprint, targetKey.fingerprint)) {
|
||||
entries.push({
|
||||
key: sourceKey.name,
|
||||
op: "unchanged",
|
||||
sourceFingerprint: sourceKey.fingerprint,
|
||||
targetFingerprint: targetKey.fingerprint,
|
||||
destructive: false
|
||||
});
|
||||
continue;
|
||||
}
|
||||
entries.push({
|
||||
key: sourceKey.name,
|
||||
// Present in target but values are not comparable (write-only target): "unknown".
|
||||
op: comparable ? "update" : "unknown",
|
||||
sourceFingerprint: sourceKey.fingerprint,
|
||||
targetFingerprint: targetKey.fingerprint,
|
||||
destructive: true
|
||||
});
|
||||
}
|
||||
|
||||
return { from, to, redacted: true, entries, createdAt: this.iso() };
|
||||
}
|
||||
|
||||
/** Build a sync plan from source -> target. Source must expose readable values. */
|
||||
async createCredentialSyncPlan(input: {
|
||||
from: CredentialEndpoint;
|
||||
to: CredentialEndpoint;
|
||||
policy?: Partial<CredentialPolicy>;
|
||||
}): Promise<CredentialSyncPlan> {
|
||||
const source = this.requireProvider(input.from.provider);
|
||||
const target = this.requireProvider(input.to.provider);
|
||||
|
||||
if (!source.capabilities.readValues) {
|
||||
throw new Error(`Provider "${source.id}" cannot read values, so it cannot be a sync source.`);
|
||||
}
|
||||
if (!target.capabilities.write) {
|
||||
throw new Error(`Provider "${target.id}" is read-only and cannot be a sync target.`);
|
||||
}
|
||||
|
||||
const policy: CredentialPolicy = { ...DEFAULT_CREDENTIAL_POLICY, ...input.policy };
|
||||
const diff = await this.diffCredentialEndpoints(input.from, input.to);
|
||||
|
||||
const denied = new Set(policy.denyKeys ?? []);
|
||||
const changes = diff.entries.filter((entry) => entry.op !== "unchanged" && !denied.has(entry.key));
|
||||
const requiresApproval = policy.requireApprovalForDestructive && changes.some((change) => change.destructive);
|
||||
|
||||
const plan: CredentialSyncPlan = {
|
||||
type: "logicsrc.credential_sync_plan",
|
||||
id: this.id("cred_plan"),
|
||||
from: input.from,
|
||||
to: input.to,
|
||||
policy,
|
||||
changes,
|
||||
requiresApproval,
|
||||
createdAt: this.iso()
|
||||
};
|
||||
this.store.savePlan(plan);
|
||||
return plan;
|
||||
}
|
||||
|
||||
approveCredentialSync(planId: string, approval: { approver?: LogicSrcPrincipal; keys?: string[] } = {}): CredentialApproval {
|
||||
const plan = this.store.getPlan(planId);
|
||||
if (!plan) {
|
||||
throw new Error(`Unknown credential sync plan: ${planId}`);
|
||||
}
|
||||
return {
|
||||
type: "logicsrc.credential_approval",
|
||||
id: this.id("cred_approval"),
|
||||
planId,
|
||||
approver: approval.approver ?? this.principal,
|
||||
approvedKeys: approval.keys ?? [],
|
||||
approvedAt: this.iso()
|
||||
};
|
||||
}
|
||||
|
||||
/** Execute a plan. Dry-run by default — pass `dryRun: false` to write. */
|
||||
async runCredentialSync(
|
||||
planId: string,
|
||||
options: { dryRun?: boolean; approval?: CredentialApproval } = {}
|
||||
): Promise<CredentialSyncRun> {
|
||||
const plan = this.store.getPlan(planId);
|
||||
if (!plan) {
|
||||
throw new Error(`Unknown credential sync plan: ${planId}`);
|
||||
}
|
||||
const dryRun = options.dryRun ?? true;
|
||||
|
||||
if (!dryRun && plan.requiresApproval) {
|
||||
this.assertApprovalCovers(plan, options.approval);
|
||||
}
|
||||
|
||||
const target = this.requireProvider(plan.to.provider);
|
||||
const upsertKeys = plan.changes.filter((c) => c.op === "add" || c.op === "update" || c.op === "unknown").map((c) => c.key);
|
||||
const deleteKeys = plan.changes.filter((c) => c.op === "remove").map((c) => c.key);
|
||||
|
||||
// Resolve the values to write. A rollback plan pulls from the origin run's vault.
|
||||
const upserts = await this.resolveSourceValues(plan, upsertKeys);
|
||||
|
||||
// Capture a rollback pre-image of the target's current values, when readable.
|
||||
const reversible = target.capabilities.readValues && target.capabilities.write && upsertKeys.length > 0;
|
||||
const runId = this.id("cred_run");
|
||||
if (reversible && !dryRun && target.readValues) {
|
||||
const preImage = await target.readValues(plan.to, [...upsertKeys, ...deleteKeys]);
|
||||
this.store.saveVault(runId, preImage);
|
||||
}
|
||||
|
||||
const writeResults = await target.write({ endpoint: plan.to, upserts, deletes: deleteKeys, dryRun });
|
||||
const writeByKey = new Map(writeResults.map((r) => [r.key, r]));
|
||||
|
||||
const decision: LogicSrcPolicyDecision = dryRun ? "allow" : plan.requiresApproval ? "approval_required" : "allow";
|
||||
const targetLabel = endpointLabel(plan.to);
|
||||
const results: CredentialKeyResult[] = [];
|
||||
const auditEvents: CredentialAuditEvent[] = [];
|
||||
|
||||
for (const change of plan.changes) {
|
||||
const written = writeByKey.get(change.key);
|
||||
const applied = !dryRun && (written?.applied ?? false);
|
||||
const fingerprint = upserts[change.key] !== undefined ? fingerprintValue(upserts[change.key]) : change.sourceFingerprint;
|
||||
results.push({
|
||||
key: change.key,
|
||||
op: change.op,
|
||||
applied,
|
||||
dryRun,
|
||||
targetFingerprint: change.op === "remove" ? undefined : fingerprint,
|
||||
error: written?.error
|
||||
});
|
||||
auditEvents.push({
|
||||
type: "logicsrc.credential_audit_event",
|
||||
id: this.id("cred_audit"),
|
||||
runId,
|
||||
planId: plan.id,
|
||||
provider: plan.to.provider,
|
||||
action: `credentials:${change.op}`,
|
||||
key: change.key,
|
||||
target: targetLabel,
|
||||
fingerprint: change.op === "remove" ? undefined : fingerprint,
|
||||
principal: options.approval?.approver ?? this.principal,
|
||||
decision,
|
||||
dryRun,
|
||||
createdAt: this.iso()
|
||||
});
|
||||
}
|
||||
|
||||
const anyError = results.some((r) => r.error);
|
||||
const status = dryRun
|
||||
? "dry_run"
|
||||
: anyError
|
||||
? results.some((r) => r.applied)
|
||||
? "partial"
|
||||
: "failed"
|
||||
: "applied";
|
||||
|
||||
const run: CredentialSyncRun = {
|
||||
type: "logicsrc.credential_sync_run",
|
||||
id: runId,
|
||||
planId: plan.id,
|
||||
status,
|
||||
dryRun,
|
||||
results,
|
||||
auditEventIds: auditEvents.map((e) => e.id),
|
||||
reversible: reversible && !dryRun,
|
||||
startedAt: plan.createdAt,
|
||||
finishedAt: this.iso()
|
||||
};
|
||||
this.store.saveRun(run);
|
||||
this.store.saveAudit(runId, auditEvents);
|
||||
return run;
|
||||
}
|
||||
|
||||
/** Produce a NEW plan that reverses a run by restoring its captured pre-image. */
|
||||
async rollbackCredentialSync(runId: string): Promise<CredentialSyncPlan> {
|
||||
const run = this.store.getRun(runId);
|
||||
if (!run) {
|
||||
throw new Error(`Unknown credential sync run: ${runId}`);
|
||||
}
|
||||
if (!run.reversible) {
|
||||
throw new Error(`Run ${runId} was not reversible (no pre-image captured). Rollbacks require a value-readable target.`);
|
||||
}
|
||||
const preImage = this.store.getVault(runId);
|
||||
if (!preImage) {
|
||||
throw new Error(`No rollback pre-image found for run ${runId}.`);
|
||||
}
|
||||
const originPlan = this.store.getPlan(run.planId);
|
||||
if (!originPlan) {
|
||||
throw new Error(`Origin plan ${run.planId} for run ${runId} is missing.`);
|
||||
}
|
||||
|
||||
// Restore prior values for keys that existed before the run...
|
||||
const restores: CredentialDiffEntry[] = Object.keys(preImage)
|
||||
.sort()
|
||||
.map((key) => ({ key, op: "update", sourceFingerprint: fingerprintValue(preImage[key]), destructive: true }));
|
||||
// ...and delete keys the run newly added (no prior value to restore).
|
||||
const deletions: CredentialDiffEntry[] = run.results
|
||||
.filter((result) => result.op === "add" && result.applied && !(result.key in preImage))
|
||||
.map((result) => ({ key: result.key, op: "remove" as const, destructive: true }));
|
||||
const changes: CredentialDiffEntry[] = [...restores, ...deletions];
|
||||
|
||||
const plan: CredentialSyncPlan = {
|
||||
type: "logicsrc.credential_sync_plan",
|
||||
id: this.id("cred_plan"),
|
||||
from: { provider: originPlan.to.provider, metadata: { rollbackVault: runId } },
|
||||
to: originPlan.to,
|
||||
policy: { ...DEFAULT_CREDENTIAL_POLICY },
|
||||
changes,
|
||||
requiresApproval: true,
|
||||
rollbackOfRunId: runId,
|
||||
createdAt: this.iso()
|
||||
};
|
||||
this.store.savePlan(plan);
|
||||
return plan;
|
||||
}
|
||||
|
||||
exportCredentialAudit(runId: string): CredentialAuditEvent[] {
|
||||
return this.store.getAudit(runId);
|
||||
}
|
||||
|
||||
// --- internals -----------------------------------------------------------
|
||||
|
||||
private assertApprovalCovers(plan: CredentialSyncPlan, approval?: CredentialApproval): void {
|
||||
if (!approval) {
|
||||
throw new Error(`Plan ${plan.id} requires approval before writing. Run "logicsrc credentials approve --plan ${plan.id}".`);
|
||||
}
|
||||
if (approval.planId !== plan.id) {
|
||||
throw new Error(`Approval ${approval.id} is for plan ${approval.planId}, not ${plan.id}.`);
|
||||
}
|
||||
if (approval.approvedKeys.length === 0) {
|
||||
return; // empty = approve all changes
|
||||
}
|
||||
const approved = new Set(approval.approvedKeys);
|
||||
const missing = plan.changes.filter((c) => c.destructive && !approved.has(c.key)).map((c) => c.key);
|
||||
if (missing.length > 0) {
|
||||
throw new Error(`Approval does not cover destructive keys: ${missing.join(", ")}`);
|
||||
}
|
||||
}
|
||||
|
||||
private async resolveSourceValues(plan: CredentialSyncPlan, keys: string[]): Promise<CredentialValueBag> {
|
||||
if (keys.length === 0) {
|
||||
return {};
|
||||
}
|
||||
if (plan.rollbackOfRunId) {
|
||||
const preImage = this.store.getVault(plan.rollbackOfRunId);
|
||||
if (!preImage) {
|
||||
throw new Error(`Rollback plan ${plan.id} references missing vault for run ${plan.rollbackOfRunId}.`);
|
||||
}
|
||||
return Object.fromEntries(keys.filter((k) => k in preImage).map((k) => [k, preImage[k]]));
|
||||
}
|
||||
const source = this.requireProvider(plan.from.provider);
|
||||
if (!source.readValues) {
|
||||
throw new Error(`Provider "${source.id}" cannot read values needed to apply the plan.`);
|
||||
}
|
||||
return source.readValues(plan.from, keys);
|
||||
}
|
||||
}
|
||||
|
||||
export function endpointLabel(endpoint: CredentialEndpoint): string {
|
||||
const parts = [endpoint.provider];
|
||||
if (endpoint.path) parts.push(endpoint.path);
|
||||
if (endpoint.project) parts.push(endpoint.project);
|
||||
if (endpoint.config) parts.push(endpoint.config);
|
||||
if (endpoint.service) parts.push(endpoint.service);
|
||||
return parts.join(":");
|
||||
}
|
||||
BIN
plugins/credential-sharing/src/fingerprint.ts
Normal file
BIN
plugins/credential-sharing/src/fingerprint.ts
Normal file
Binary file not shown.
66
plugins/credential-sharing/src/index.ts
Normal file
66
plugins/credential-sharing/src/index.ts
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
import type { PluginDefinition } from "@logicsrc/plugin-core";
|
||||
import { credentialSharingManifest } from "./manifest.js";
|
||||
import { CredentialEngine, type CredentialEngineOptions } from "./engine.js";
|
||||
import { listCredentialProviderManifests } from "./providers/index.js";
|
||||
|
||||
export const credentialSharingPlugin: PluginDefinition = {
|
||||
manifest: credentialSharingManifest,
|
||||
configDefaults: {
|
||||
enabled: true,
|
||||
default_policy: "approval_required_for_destructive",
|
||||
credential_home: "${LOGICSRC_CREDENTIAL_HOME}"
|
||||
},
|
||||
routes: [
|
||||
{ method: "GET", path: "/api/credentials/providers", capability: "credentials.providers.list" },
|
||||
{ method: "GET", path: "/api/credentials/inspect", capability: "credentials.inspect" },
|
||||
{ method: "POST", path: "/api/credentials/diff", capability: "credentials.diff" },
|
||||
{ method: "POST", path: "/api/credentials/plans", capability: "credentials.plan" },
|
||||
{ method: "POST", path: "/api/credentials/plans/:id/approve", capability: "credentials.approve" },
|
||||
{ method: "POST", path: "/api/credentials/plans/:id/sync", capability: "credentials.sync" },
|
||||
{ method: "POST", path: "/api/credentials/runs/:id/rollback", capability: "credentials.rollback" },
|
||||
{ method: "GET", path: "/api/credentials/runs/:id/audit", capability: "credentials.audit.read" }
|
||||
],
|
||||
permissions: [
|
||||
"credentials:inspect",
|
||||
"credentials:diff",
|
||||
"credentials:plan",
|
||||
"credentials:approve",
|
||||
"credentials:sync",
|
||||
"credentials:rollback",
|
||||
"credentials:audit:read"
|
||||
],
|
||||
tuiPanels: [{ id: "credential-sharing", title: "Credential Sharing" }]
|
||||
};
|
||||
|
||||
/** Factory mirroring the LogicSRC Credential Sharing SDK spec. */
|
||||
export function createCredentialEngine(options: CredentialEngineOptions = {}): CredentialEngine {
|
||||
return new CredentialEngine(options);
|
||||
}
|
||||
|
||||
/** Provider listing without constructing an engine (used by the CLI `providers` command). */
|
||||
export function listCredentialProviders() {
|
||||
return listCredentialProviderManifests();
|
||||
}
|
||||
|
||||
export { credentialSharingManifest };
|
||||
export { CredentialEngine, DEFAULT_CREDENTIAL_POLICY, endpointLabel } from "./engine.js";
|
||||
export type { CredentialEngineOptions } from "./engine.js";
|
||||
export {
|
||||
credentialProviders,
|
||||
credentialProviderRegistry,
|
||||
listCredentialProviderManifests,
|
||||
envProvider,
|
||||
dopplerProvider,
|
||||
railwayProvider,
|
||||
githubSecretsProvider,
|
||||
parseEnv,
|
||||
applyEnv
|
||||
} from "./providers/index.js";
|
||||
export {
|
||||
createFileCredentialStore,
|
||||
createMemoryCredentialStore,
|
||||
defaultCredentialHome,
|
||||
type CredentialStore
|
||||
} from "./store.js";
|
||||
export { fingerprintValue, fingerprintsEqual } from "./fingerprint.js";
|
||||
export * from "./types.js";
|
||||
22
plugins/credential-sharing/src/manifest.ts
Normal file
22
plugins/credential-sharing/src/manifest.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import type { PluginManifest } from "@logicsrc/plugin-core";
|
||||
|
||||
export const credentialSharingManifest: PluginManifest = {
|
||||
id: "credential-sharing",
|
||||
name: "Credential Sharing",
|
||||
version: "0.1.0",
|
||||
type: ["credentials", "secrets", "sync"],
|
||||
default: true,
|
||||
capabilities: [
|
||||
"credentials.providers.list",
|
||||
"credentials.inspect",
|
||||
"credentials.diff",
|
||||
"credentials.plan",
|
||||
"credentials.approve",
|
||||
"credentials.sync",
|
||||
"credentials.rollback",
|
||||
"credentials.audit.read",
|
||||
"credentials.export"
|
||||
],
|
||||
commands: ["credentials"],
|
||||
env: ["DOPPLER_TOKEN", "RAILWAY_TOKEN", "GITHUB_TOKEN", "LOGICSRC_CREDENTIAL_HOME"]
|
||||
};
|
||||
75
plugins/credential-sharing/src/providers/doppler.ts
Normal file
75
plugins/credential-sharing/src/providers/doppler.ts
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
import { keysFromValues } from "../fingerprint.js";
|
||||
import { httpJson, requireEnv } from "./http.js";
|
||||
import type { CredentialEndpoint, CredentialProvider, CredentialValueBag, CredentialWriteResult } from "../types.js";
|
||||
|
||||
const DOPPLER_API = "https://api.doppler.com/v3";
|
||||
|
||||
function auth(): string {
|
||||
const token = requireEnv("DOPPLER_TOKEN", "Create a Doppler service token and export it as DOPPLER_TOKEN.");
|
||||
return `Bearer ${token}`;
|
||||
}
|
||||
|
||||
function scopeQuery(endpoint: CredentialEndpoint): string {
|
||||
const params = new URLSearchParams();
|
||||
if (endpoint.project) params.set("project", endpoint.project);
|
||||
if (endpoint.config) params.set("config", endpoint.config);
|
||||
const qs = params.toString();
|
||||
return qs ? `?${qs}` : "";
|
||||
}
|
||||
|
||||
interface DopplerSecretsResponse {
|
||||
secrets: Record<string, { raw?: string; computed?: string }>;
|
||||
}
|
||||
|
||||
async function fetchSecrets(endpoint: CredentialEndpoint): Promise<CredentialValueBag> {
|
||||
const data = await httpJson<DopplerSecretsResponse>(`${DOPPLER_API}/configs/config/secrets${scopeQuery(endpoint)}`, {
|
||||
headers: { Authorization: auth(), accept: "application/json" },
|
||||
expect: "Doppler list secrets"
|
||||
});
|
||||
const out: CredentialValueBag = {};
|
||||
for (const [name, value] of Object.entries(data.secrets ?? {})) {
|
||||
out[name] = value.raw ?? value.computed ?? "";
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export const dopplerProvider: CredentialProvider = {
|
||||
id: "doppler",
|
||||
name: "Doppler",
|
||||
description: "Sync project/config scoped secrets.",
|
||||
capabilities: { readValues: true, readNames: true, write: true, delete: true, rollback: true, audit: false },
|
||||
authRequirements: ["DOPPLER_TOKEN"],
|
||||
status: "available",
|
||||
|
||||
async inspect(endpoint) {
|
||||
const values = await fetchSecrets(endpoint);
|
||||
return { provider: "doppler", endpoint, valuesReadable: true, keys: keysFromValues(values), inspectedAt: new Date().toISOString() };
|
||||
},
|
||||
|
||||
async readValues(endpoint, keys) {
|
||||
const values = await fetchSecrets(endpoint);
|
||||
return Object.fromEntries(keys.filter((k) => k in values).map((k) => [k, values[k]]));
|
||||
},
|
||||
|
||||
async write({ endpoint, upserts, deletes, dryRun }) {
|
||||
const results: CredentialWriteResult[] = [
|
||||
...Object.keys(upserts).map((key) => ({ key, applied: !dryRun })),
|
||||
...deletes.map((key) => ({ key, applied: !dryRun }))
|
||||
];
|
||||
if (dryRun) {
|
||||
return results;
|
||||
}
|
||||
// Doppler deletes a secret when its value is set to null.
|
||||
const secrets: Record<string, string | null> = { ...upserts };
|
||||
for (const key of deletes) {
|
||||
secrets[key] = null;
|
||||
}
|
||||
await httpJson(`${DOPPLER_API}/configs/config/secrets`, {
|
||||
method: "POST",
|
||||
headers: { Authorization: auth(), "content-type": "application/json" },
|
||||
body: JSON.stringify({ project: endpoint.project, config: endpoint.config, secrets }),
|
||||
expect: "Doppler update secrets"
|
||||
});
|
||||
return results;
|
||||
}
|
||||
};
|
||||
30
plugins/credential-sharing/src/providers/env.test.ts
Normal file
30
plugins/credential-sharing/src/providers/env.test.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { parseEnv, applyEnv } from "./env.js";
|
||||
|
||||
describe("env provider parsing", () => {
|
||||
it("parses plain, quoted, and exported lines and ignores comments", () => {
|
||||
const body = ["# comment", "A=1", 'B="two words"', "export C=three", "", "D='quoted'"].join("\n");
|
||||
expect(parseEnv(body)).toEqual({ A: "1", B: "two words", C: "three", D: "quoted" });
|
||||
});
|
||||
|
||||
it("decodes escaped newlines inside double quotes", () => {
|
||||
expect(parseEnv('KEY="line1\\nline2"')).toEqual({ KEY: "line1\nline2" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("env provider merge", () => {
|
||||
it("updates existing keys in place and appends new ones", () => {
|
||||
const body = "# header\nA=1\nB=2\n";
|
||||
const next = applyEnv(body, { A: "10", C: "3" }, []);
|
||||
expect(next).toBe("# header\nA=10\nB=2\nC=3\n");
|
||||
});
|
||||
|
||||
it("removes deleted keys but preserves comments", () => {
|
||||
const body = "# header\nA=1\nB=2\n";
|
||||
expect(applyEnv(body, {}, ["B"])).toBe("# header\nA=1\n");
|
||||
});
|
||||
|
||||
it("quotes values that need it", () => {
|
||||
expect(applyEnv("", { A: "two words" }, [])).toBe('A="two words"\n');
|
||||
});
|
||||
});
|
||||
142
plugins/credential-sharing/src/providers/env.ts
Normal file
142
plugins/credential-sharing/src/providers/env.ts
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
import { readFileSync, writeFileSync, existsSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { keysFromValues } from "../fingerprint.js";
|
||||
import type { CredentialEndpoint, CredentialProvider, CredentialValueBag, CredentialWriteResult } from "../types.js";
|
||||
|
||||
const QUOTED = /^(['"])(.*)\1$/s;
|
||||
|
||||
/** Parse a `.env` file body into a value bag. Supports quotes and `export` prefixes. */
|
||||
export function parseEnv(body: string): CredentialValueBag {
|
||||
const out: CredentialValueBag = {};
|
||||
for (const rawLine of body.split(/\r?\n/)) {
|
||||
const line = rawLine.trim();
|
||||
if (!line || line.startsWith("#")) {
|
||||
continue;
|
||||
}
|
||||
const withoutExport = line.startsWith("export ") ? line.slice("export ".length) : line;
|
||||
const eq = withoutExport.indexOf("=");
|
||||
if (eq === -1) {
|
||||
continue;
|
||||
}
|
||||
const key = withoutExport.slice(0, eq).trim();
|
||||
if (!key) {
|
||||
continue;
|
||||
}
|
||||
let value = withoutExport.slice(eq + 1).trim();
|
||||
const quoted = QUOTED.exec(value);
|
||||
if (quoted) {
|
||||
value = quoted[2];
|
||||
if (quoted[1] === '"') {
|
||||
value = value.replace(/\\n/g, "\n").replace(/\\"/g, '"');
|
||||
}
|
||||
}
|
||||
out[key] = value;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function needsQuoting(value: string): boolean {
|
||||
return /[\s#'"=]|^$/.test(value) || value.includes("\n");
|
||||
}
|
||||
|
||||
function serializeValue(value: string): string {
|
||||
if (!needsQuoting(value)) {
|
||||
return value;
|
||||
}
|
||||
return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\n/g, "\\n")}"`;
|
||||
}
|
||||
|
||||
/** Merge upserts/deletes into an existing `.env` body, preserving comments and order. */
|
||||
export function applyEnv(body: string, upserts: CredentialValueBag, deletes: string[]): string {
|
||||
const deleteSet = new Set(deletes);
|
||||
const remaining = new Map(Object.entries(upserts));
|
||||
const lines = body.split(/\r?\n/);
|
||||
const output: string[] = [];
|
||||
|
||||
for (const rawLine of lines) {
|
||||
const trimmed = rawLine.trim();
|
||||
if (!trimmed || trimmed.startsWith("#")) {
|
||||
output.push(rawLine);
|
||||
continue;
|
||||
}
|
||||
const withoutExport = trimmed.startsWith("export ") ? trimmed.slice("export ".length) : trimmed;
|
||||
const eq = withoutExport.indexOf("=");
|
||||
const key = eq === -1 ? "" : withoutExport.slice(0, eq).trim();
|
||||
if (key && deleteSet.has(key)) {
|
||||
continue;
|
||||
}
|
||||
if (key && remaining.has(key)) {
|
||||
output.push(`${key}=${serializeValue(remaining.get(key) as string)}`);
|
||||
remaining.delete(key);
|
||||
continue;
|
||||
}
|
||||
output.push(rawLine);
|
||||
}
|
||||
|
||||
if (remaining.size > 0) {
|
||||
// Drop the blank line(s) a trailing newline left behind so new keys append cleanly.
|
||||
while (output.length > 0 && output[output.length - 1].trim() === "") {
|
||||
output.pop();
|
||||
}
|
||||
for (const [key, value] of remaining) {
|
||||
output.push(`${key}=${serializeValue(value)}`);
|
||||
}
|
||||
}
|
||||
|
||||
let result = output.join("\n");
|
||||
if (!result.endsWith("\n")) {
|
||||
result += "\n";
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function endpointPath(endpoint: CredentialEndpoint): string {
|
||||
return resolve(process.cwd(), endpoint.path ?? ".env");
|
||||
}
|
||||
|
||||
function readBody(endpoint: CredentialEndpoint): string {
|
||||
const file = endpointPath(endpoint);
|
||||
return existsSync(file) ? readFileSync(file, "utf8") : "";
|
||||
}
|
||||
|
||||
export const envProvider: CredentialProvider = {
|
||||
id: "env",
|
||||
name: "Local .env file",
|
||||
description: "Read, diff, redact, and write local environment files.",
|
||||
capabilities: { readValues: true, readNames: true, write: true, delete: true, rollback: true, audit: false },
|
||||
authRequirements: [],
|
||||
status: "available",
|
||||
|
||||
async inspect(endpoint) {
|
||||
const values = parseEnv(readBody(endpoint));
|
||||
return {
|
||||
provider: "env",
|
||||
endpoint,
|
||||
valuesReadable: true,
|
||||
keys: keysFromValues(values),
|
||||
inspectedAt: new Date().toISOString()
|
||||
};
|
||||
},
|
||||
|
||||
async readValues(endpoint, keys) {
|
||||
const values = parseEnv(readBody(endpoint));
|
||||
return Object.fromEntries(keys.filter((k) => k in values).map((k) => [k, values[k]]));
|
||||
},
|
||||
|
||||
async write({ endpoint, upserts, deletes, dryRun }) {
|
||||
const results: CredentialWriteResult[] = [
|
||||
...Object.keys(upserts).map((key) => ({ key, applied: !dryRun })),
|
||||
...deletes.map((key) => ({ key, applied: !dryRun }))
|
||||
];
|
||||
if (dryRun) {
|
||||
return results;
|
||||
}
|
||||
const next = applyEnv(readBody(endpoint), upserts, deletes);
|
||||
writeFileSync(endpointPath(endpoint), next, { mode: 0o600 });
|
||||
return results;
|
||||
},
|
||||
|
||||
async rollback({ endpoint, preImage, dryRun }) {
|
||||
return this.write({ endpoint, upserts: preImage, deletes: [], dryRun });
|
||||
}
|
||||
};
|
||||
116
plugins/credential-sharing/src/providers/github-secrets.ts
Normal file
116
plugins/credential-sharing/src/providers/github-secrets.ts
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
import { keysFromNames } from "../fingerprint.js";
|
||||
import { httpJson, requireEnv } from "./http.js";
|
||||
import type { CredentialEndpoint, CredentialProvider, CredentialWriteResult } from "../types.js";
|
||||
|
||||
const GITHUB_API = "https://api.github.com";
|
||||
|
||||
function headers(): Record<string, string> {
|
||||
const token = requireEnv("GITHUB_TOKEN", "Export a GitHub token with secrets:write scope as GITHUB_TOKEN.");
|
||||
return {
|
||||
Authorization: `Bearer ${token}`,
|
||||
accept: "application/vnd.github+json",
|
||||
"x-github-api-version": "2022-11-28"
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the Actions-secrets base path for an endpoint.
|
||||
* repo (default): project=owner, service=repo
|
||||
* org: scope="org", project=org
|
||||
* environment: scope="environment", project=owner, service=repo, config=environment
|
||||
*/
|
||||
function basePath(endpoint: CredentialEndpoint): string {
|
||||
const scope = endpoint.scope ?? "repo";
|
||||
if (scope === "org") {
|
||||
if (!endpoint.project) throw new Error('GitHub org secrets need project=<org>.');
|
||||
return `/orgs/${endpoint.project}/actions/secrets`;
|
||||
}
|
||||
if (!endpoint.project || !endpoint.service) {
|
||||
throw new Error('GitHub repo secrets need project=<owner> and service=<repo>.');
|
||||
}
|
||||
if (scope === "environment") {
|
||||
if (!endpoint.config) throw new Error('GitHub environment secrets need config=<environment>.');
|
||||
return `/repos/${endpoint.project}/${endpoint.service}/environments/${endpoint.config}/secrets`;
|
||||
}
|
||||
return `/repos/${endpoint.project}/${endpoint.service}/actions/secrets`;
|
||||
}
|
||||
|
||||
interface SecretsList {
|
||||
secrets: Array<{ name: string; updated_at?: string }>;
|
||||
}
|
||||
|
||||
interface PublicKey {
|
||||
key_id: string;
|
||||
key: string;
|
||||
}
|
||||
|
||||
async function sealValue(value: string, publicKeyB64: string): Promise<string> {
|
||||
const sodiumModule = (await import("libsodium-wrappers")) as unknown as { default?: SodiumLike } & SodiumLike;
|
||||
const sodium: SodiumLike = sodiumModule.default ?? sodiumModule;
|
||||
await sodium.ready;
|
||||
const key = sodium.from_base64(publicKeyB64, sodium.base64_variants.ORIGINAL);
|
||||
const sealed = sodium.crypto_box_seal(sodium.from_string(value), key);
|
||||
return sodium.to_base64(sealed, sodium.base64_variants.ORIGINAL);
|
||||
}
|
||||
|
||||
interface SodiumLike {
|
||||
ready: Promise<void>;
|
||||
base64_variants: { ORIGINAL: number };
|
||||
from_base64(input: string, variant: number): Uint8Array;
|
||||
to_base64(input: Uint8Array, variant: number): string;
|
||||
from_string(input: string): Uint8Array;
|
||||
crypto_box_seal(message: Uint8Array, publicKey: Uint8Array): Uint8Array;
|
||||
}
|
||||
|
||||
export const githubSecretsProvider: CredentialProvider = {
|
||||
id: "github-secrets",
|
||||
name: "GitHub Secrets",
|
||||
description: "Sync repository, organization, and environment secrets.",
|
||||
// GitHub never returns secret values — names only. So values are not readable
|
||||
// and a github-secrets endpoint cannot be a sync source or a rollback target.
|
||||
capabilities: { readValues: false, readNames: true, write: true, delete: true, rollback: false, audit: false },
|
||||
authRequirements: ["GITHUB_TOKEN"],
|
||||
status: "available",
|
||||
|
||||
async inspect(endpoint) {
|
||||
const list = await httpJson<SecretsList>(`${GITHUB_API}${basePath(endpoint)}?per_page=100`, {
|
||||
headers: headers(),
|
||||
expect: "GitHub list secrets"
|
||||
});
|
||||
const keys = keysFromNames((list.secrets ?? []).map((s) => s.name));
|
||||
for (const secret of list.secrets ?? []) {
|
||||
const key = keys.find((k) => k.name === secret.name);
|
||||
if (key) key.lastModifiedAt = secret.updated_at;
|
||||
}
|
||||
return { provider: "github-secrets", endpoint, valuesReadable: false, keys, inspectedAt: new Date().toISOString() };
|
||||
},
|
||||
|
||||
async write({ endpoint, upserts, deletes, dryRun }) {
|
||||
const results: CredentialWriteResult[] = [
|
||||
...Object.keys(upserts).map((key) => ({ key, applied: !dryRun })),
|
||||
...deletes.map((key) => ({ key, applied: !dryRun }))
|
||||
];
|
||||
if (dryRun) {
|
||||
return results;
|
||||
}
|
||||
const base = basePath(endpoint);
|
||||
const visibility = endpoint.scope === "org" ? { visibility: "all" as const } : {};
|
||||
let publicKey: PublicKey | undefined;
|
||||
if (Object.keys(upserts).length > 0) {
|
||||
publicKey = await httpJson<PublicKey>(`${GITHUB_API}${base}/public-key`, { headers: headers(), expect: "GitHub public key" });
|
||||
}
|
||||
for (const [name, value] of Object.entries(upserts)) {
|
||||
const encrypted_value = await sealValue(value, (publicKey as PublicKey).key);
|
||||
await httpJson(`${GITHUB_API}${base}/${name}`, {
|
||||
method: "PUT",
|
||||
headers: { ...headers(), "content-type": "application/json" },
|
||||
body: JSON.stringify({ encrypted_value, key_id: (publicKey as PublicKey).key_id, ...visibility }),
|
||||
expect: `GitHub put secret ${name}`
|
||||
});
|
||||
}
|
||||
for (const name of deletes) {
|
||||
await httpJson(`${GITHUB_API}${base}/${name}`, { method: "DELETE", headers: headers(), expect: `GitHub delete secret ${name}` });
|
||||
}
|
||||
return results;
|
||||
}
|
||||
};
|
||||
23
plugins/credential-sharing/src/providers/http.ts
Normal file
23
plugins/credential-sharing/src/providers/http.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
/** Minimal fetch helper shared by the network credential providers. */
|
||||
export function requireEnv(name: string, hint: string): string {
|
||||
const value = process.env[name];
|
||||
if (!value) {
|
||||
throw new Error(`Missing ${name}. ${hint}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export async function httpJson<T>(
|
||||
url: string,
|
||||
init: RequestInit & { expect?: string } = {}
|
||||
): Promise<T> {
|
||||
const response = await fetch(url, init);
|
||||
if (!response.ok) {
|
||||
const body = await response.text().catch(() => "");
|
||||
throw new Error(`${init.expect ?? "Request"} failed: ${response.status} ${response.statusText} ${body.slice(0, 300)}`.trim());
|
||||
}
|
||||
if (response.status === 204) {
|
||||
return undefined as T;
|
||||
}
|
||||
return (await response.json()) as T;
|
||||
}
|
||||
25
plugins/credential-sharing/src/providers/index.ts
Normal file
25
plugins/credential-sharing/src/providers/index.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import type { CredentialProvider, CredentialProviderManifest } from "../types.js";
|
||||
import { envProvider } from "./env.js";
|
||||
import { dopplerProvider } from "./doppler.js";
|
||||
import { railwayProvider } from "./railway.js";
|
||||
import { githubSecretsProvider } from "./github-secrets.js";
|
||||
|
||||
export const credentialProviders: CredentialProvider[] = [envProvider, dopplerProvider, railwayProvider, githubSecretsProvider];
|
||||
|
||||
export const credentialProviderRegistry: Map<string, CredentialProvider> = new Map(
|
||||
credentialProviders.map((provider) => [provider.id, provider])
|
||||
);
|
||||
|
||||
export function listCredentialProviderManifests(): CredentialProviderManifest[] {
|
||||
return credentialProviders.map(({ id, name, description, capabilities, authRequirements, status }) => ({
|
||||
id,
|
||||
name,
|
||||
description,
|
||||
capabilities,
|
||||
authRequirements,
|
||||
status
|
||||
}));
|
||||
}
|
||||
|
||||
export { envProvider, dopplerProvider, railwayProvider, githubSecretsProvider };
|
||||
export { parseEnv, applyEnv } from "./env.js";
|
||||
88
plugins/credential-sharing/src/providers/railway.ts
Normal file
88
plugins/credential-sharing/src/providers/railway.ts
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
import { keysFromValues } from "../fingerprint.js";
|
||||
import { httpJson, requireEnv } from "./http.js";
|
||||
import type { CredentialEndpoint, CredentialProvider, CredentialValueBag, CredentialWriteResult } from "../types.js";
|
||||
|
||||
const RAILWAY_API = "https://backboard.railway.app/graphql/v2";
|
||||
|
||||
function auth(): string {
|
||||
const token = requireEnv("RAILWAY_TOKEN", "Create a Railway account/project token and export it as RAILWAY_TOKEN.");
|
||||
return `Bearer ${token}`;
|
||||
}
|
||||
|
||||
/** Railway addresses variables by project/environment(/service). project=projectId, config=environmentId, service=serviceId. */
|
||||
function scope(endpoint: CredentialEndpoint): { projectId: string; environmentId: string; serviceId?: string } {
|
||||
if (!endpoint.project || !endpoint.config) {
|
||||
throw new Error('Railway endpoint needs project (projectId) and config (environmentId), e.g. --to-project <projectId> --to-config <environmentId>.');
|
||||
}
|
||||
return { projectId: endpoint.project, environmentId: endpoint.config, serviceId: endpoint.service };
|
||||
}
|
||||
|
||||
async function gql<T>(query: string, variables: Record<string, unknown>, expect: string): Promise<T> {
|
||||
const data = await httpJson<{ data?: T; errors?: Array<{ message: string }> }>(RAILWAY_API, {
|
||||
method: "POST",
|
||||
headers: { Authorization: auth(), "content-type": "application/json" },
|
||||
body: JSON.stringify({ query, variables }),
|
||||
expect
|
||||
});
|
||||
if (data.errors?.length) {
|
||||
throw new Error(`${expect} failed: ${data.errors.map((e) => e.message).join("; ")}`);
|
||||
}
|
||||
return data.data as T;
|
||||
}
|
||||
|
||||
async function fetchVariables(endpoint: CredentialEndpoint): Promise<CredentialValueBag> {
|
||||
const { projectId, environmentId, serviceId } = scope(endpoint);
|
||||
const data = await gql<{ variables: Record<string, string> }>(
|
||||
`query Variables($projectId: String!, $environmentId: String!, $serviceId: String) {
|
||||
variables(projectId: $projectId, environmentId: $environmentId, serviceId: $serviceId)
|
||||
}`,
|
||||
{ projectId, environmentId, serviceId },
|
||||
"Railway list variables"
|
||||
);
|
||||
return data.variables ?? {};
|
||||
}
|
||||
|
||||
export const railwayProvider: CredentialProvider = {
|
||||
id: "railway",
|
||||
name: "Railway",
|
||||
description: "Sync service variables.",
|
||||
capabilities: { readValues: true, readNames: true, write: true, delete: true, rollback: true, audit: false },
|
||||
authRequirements: ["RAILWAY_TOKEN"],
|
||||
status: "available",
|
||||
|
||||
async inspect(endpoint) {
|
||||
const values = await fetchVariables(endpoint);
|
||||
return { provider: "railway", endpoint, valuesReadable: true, keys: keysFromValues(values), inspectedAt: new Date().toISOString() };
|
||||
},
|
||||
|
||||
async readValues(endpoint, keys) {
|
||||
const values = await fetchVariables(endpoint);
|
||||
return Object.fromEntries(keys.filter((k) => k in values).map((k) => [k, values[k]]));
|
||||
},
|
||||
|
||||
async write({ endpoint, upserts, deletes, dryRun }) {
|
||||
const results: CredentialWriteResult[] = [
|
||||
...Object.keys(upserts).map((key) => ({ key, applied: !dryRun })),
|
||||
...deletes.map((key) => ({ key, applied: !dryRun }))
|
||||
];
|
||||
if (dryRun) {
|
||||
return results;
|
||||
}
|
||||
const { projectId, environmentId, serviceId } = scope(endpoint);
|
||||
for (const [name, value] of Object.entries(upserts)) {
|
||||
await gql(
|
||||
`mutation Upsert($input: VariableUpsertInput!) { variableUpsert(input: $input) }`,
|
||||
{ input: { projectId, environmentId, serviceId, name, value } },
|
||||
`Railway upsert ${name}`
|
||||
);
|
||||
}
|
||||
for (const name of deletes) {
|
||||
await gql(
|
||||
`mutation Delete($input: VariableDeleteInput!) { variableDelete(input: $input) }`,
|
||||
{ input: { projectId, environmentId, serviceId, name } },
|
||||
`Railway delete ${name}`
|
||||
);
|
||||
}
|
||||
return results;
|
||||
}
|
||||
};
|
||||
121
plugins/credential-sharing/src/store.ts
Normal file
121
plugins/credential-sharing/src/store.ts
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
import { mkdirSync, readFileSync, writeFileSync, existsSync, readdirSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
import type { CredentialSyncPlan, CredentialSyncRun, CredentialAuditEvent, CredentialValueBag } from "./types.js";
|
||||
|
||||
/**
|
||||
* File-backed store so the CLI can reference plans/runs by id across invocations.
|
||||
*
|
||||
* Layout under the base dir (default `$LOGICSRC_CREDENTIAL_HOME` or
|
||||
* `<cwd>/.logicsrc/credentials`):
|
||||
* plans/<id>.json redacted sync plans (fingerprints only)
|
||||
* runs/<id>.json run records (fingerprints only)
|
||||
* audit/<runId>.json audit events (fingerprints only)
|
||||
* vault/<runId>.json rollback pre-image — RAW prior target values, mode 0600
|
||||
*
|
||||
* The vault is the only place raw values touch disk, and only to make rollback
|
||||
* possible. It is written 0600 and lives under a `.logicsrc` dir that callers
|
||||
* should gitignore. Audit and plan records never contain raw values.
|
||||
*/
|
||||
export interface CredentialStore {
|
||||
baseDir: string;
|
||||
savePlan(plan: CredentialSyncPlan): void;
|
||||
getPlan(id: string): CredentialSyncPlan | undefined;
|
||||
saveRun(run: CredentialSyncRun): void;
|
||||
getRun(id: string): CredentialSyncRun | undefined;
|
||||
saveAudit(runId: string, events: CredentialAuditEvent[]): void;
|
||||
getAudit(runId: string): CredentialAuditEvent[];
|
||||
saveVault(runId: string, preImage: CredentialValueBag): void;
|
||||
getVault(runId: string): CredentialValueBag | undefined;
|
||||
}
|
||||
|
||||
export function defaultCredentialHome(): string {
|
||||
if (process.env.LOGICSRC_CREDENTIAL_HOME) {
|
||||
return resolve(process.env.LOGICSRC_CREDENTIAL_HOME);
|
||||
}
|
||||
if (process.env.LOGICSRC_HOME) {
|
||||
return resolve(process.env.LOGICSRC_HOME, "credentials");
|
||||
}
|
||||
return resolve(process.cwd(), ".logicsrc", "credentials");
|
||||
}
|
||||
|
||||
function readJson<T>(file: string): T | undefined {
|
||||
if (!existsSync(file)) {
|
||||
return undefined;
|
||||
}
|
||||
return JSON.parse(readFileSync(file, "utf8")) as T;
|
||||
}
|
||||
|
||||
export function createFileCredentialStore(baseDir = defaultCredentialHome()): CredentialStore {
|
||||
const dirs = {
|
||||
plans: join(baseDir, "plans"),
|
||||
runs: join(baseDir, "runs"),
|
||||
audit: join(baseDir, "audit"),
|
||||
vault: join(baseDir, "vault")
|
||||
};
|
||||
|
||||
function ensure(dir: string, mode = 0o700) {
|
||||
mkdirSync(dir, { recursive: true, mode });
|
||||
}
|
||||
|
||||
return {
|
||||
baseDir,
|
||||
savePlan(plan) {
|
||||
ensure(dirs.plans);
|
||||
writeFileSync(join(dirs.plans, `${plan.id}.json`), JSON.stringify(plan, null, 2));
|
||||
},
|
||||
getPlan(id) {
|
||||
return readJson<CredentialSyncPlan>(join(dirs.plans, `${id}.json`));
|
||||
},
|
||||
saveRun(run) {
|
||||
ensure(dirs.runs);
|
||||
writeFileSync(join(dirs.runs, `${run.id}.json`), JSON.stringify(run, null, 2));
|
||||
},
|
||||
getRun(id) {
|
||||
return readJson<CredentialSyncRun>(join(dirs.runs, `${id}.json`));
|
||||
},
|
||||
saveAudit(runId, events) {
|
||||
ensure(dirs.audit);
|
||||
writeFileSync(join(dirs.audit, `${runId}.json`), JSON.stringify(events, null, 2));
|
||||
},
|
||||
getAudit(runId) {
|
||||
return readJson<CredentialAuditEvent[]>(join(dirs.audit, `${runId}.json`)) ?? [];
|
||||
},
|
||||
saveVault(runId, preImage) {
|
||||
ensure(dirs.vault, 0o700);
|
||||
writeFileSync(join(dirs.vault, `${runId}.json`), JSON.stringify(preImage, null, 2), { mode: 0o600 });
|
||||
},
|
||||
getVault(runId) {
|
||||
return readJson<CredentialValueBag>(join(dirs.vault, `${runId}.json`));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/** In-memory store for tests and ephemeral SDK usage (no disk writes). */
|
||||
export function createMemoryCredentialStore(): CredentialStore {
|
||||
const plans = new Map<string, CredentialSyncPlan>();
|
||||
const runs = new Map<string, CredentialSyncRun>();
|
||||
const audit = new Map<string, CredentialAuditEvent[]>();
|
||||
const vault = new Map<string, CredentialValueBag>();
|
||||
return {
|
||||
baseDir: ":memory:",
|
||||
savePlan: (plan) => void plans.set(plan.id, plan),
|
||||
getPlan: (id) => plans.get(id),
|
||||
saveRun: (run) => void runs.set(run.id, run),
|
||||
getRun: (id) => runs.get(id),
|
||||
saveAudit: (runId, events) => void audit.set(runId, events),
|
||||
getAudit: (runId) => audit.get(runId) ?? [],
|
||||
saveVault: (runId, preImage) => void vault.set(runId, preImage),
|
||||
getVault: (runId) => vault.get(runId)
|
||||
};
|
||||
}
|
||||
|
||||
export function listPlanIds(store: CredentialStore): string[] {
|
||||
const dir = join(store.baseDir, "plans");
|
||||
if (store.baseDir === ":memory:" || !existsSync(dir)) {
|
||||
return [];
|
||||
}
|
||||
return readdirSync(dir)
|
||||
.filter((f) => f.endsWith(".json"))
|
||||
.map((f) => f.replace(/\.json$/, ""));
|
||||
}
|
||||
198
plugins/credential-sharing/src/types.ts
Normal file
198
plugins/credential-sharing/src/types.ts
Normal file
|
|
@ -0,0 +1,198 @@
|
|||
import type { LogicSrcPrincipal, LogicSrcPolicyDecision } from "@logicsrc/account-core";
|
||||
|
||||
/**
|
||||
* LogicSRC Credential Sharing OpenSpec — core object + provider-contract types.
|
||||
*
|
||||
* Security invariants (see docs/credential-sharing.md):
|
||||
* - Raw secret values never appear in any object returned to a caller for display.
|
||||
* - Audit records carry key names, targets, timestamps, actor identity, and value
|
||||
* fingerprints — never raw values.
|
||||
* - Every write supports dry-run.
|
||||
* - Adapters declare read/write capabilities before a plan is generated.
|
||||
*/
|
||||
|
||||
export type CredentialProviderId = "env" | "doppler" | "railway" | "github-secrets" | (string & {});
|
||||
|
||||
export interface CredentialProviderCapabilities {
|
||||
/** Adapter can read raw secret values (enables value-level fingerprint diffs). */
|
||||
readValues: boolean;
|
||||
/** Adapter can enumerate key names (even if values are write-only). */
|
||||
readNames: boolean;
|
||||
/** Adapter can write/update secret values. */
|
||||
write: boolean;
|
||||
/** Adapter can delete keys. */
|
||||
delete: boolean;
|
||||
/** Adapter can restore a captured pre-image. */
|
||||
rollback: boolean;
|
||||
/** Adapter exposes a native audit trail. */
|
||||
audit: boolean;
|
||||
}
|
||||
|
||||
/** A `credential_source` / `credential_target`: a provider plus its addressing scope. */
|
||||
export interface CredentialEndpoint {
|
||||
provider: CredentialProviderId;
|
||||
/** Local file path for the `env` provider. */
|
||||
path?: string;
|
||||
/** Provider project handle (Doppler project, Railway project id, GitHub owner). */
|
||||
project?: string;
|
||||
/** Provider config/environment (Doppler config, Railway environment, GitHub environment). */
|
||||
config?: string;
|
||||
/** Optional sub-scope (Railway service id, GitHub repo, GitHub org). */
|
||||
service?: string;
|
||||
/** Provider scope label used in GitHub Secrets: "repo" | "org" | "environment". */
|
||||
scope?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** A redacted view of a single key — names + fingerprints only, never values. */
|
||||
export interface CredentialKey {
|
||||
name: string;
|
||||
present: boolean;
|
||||
/** Deterministic fingerprint of the value, or undefined when the provider is write-only. */
|
||||
fingerprint?: string;
|
||||
lastModifiedAt?: string;
|
||||
}
|
||||
|
||||
/** Result of `inspect()` — a redacted snapshot of an endpoint. */
|
||||
export interface CredentialSnapshot {
|
||||
provider: CredentialProviderId;
|
||||
endpoint: CredentialEndpoint;
|
||||
/** True when fingerprints reflect real values (provider can read values). */
|
||||
valuesReadable: boolean;
|
||||
keys: CredentialKey[];
|
||||
inspectedAt: string;
|
||||
}
|
||||
|
||||
export type CredentialDiffOp = "add" | "update" | "remove" | "unchanged" | "unknown";
|
||||
|
||||
export interface CredentialDiffEntry {
|
||||
key: string;
|
||||
op: CredentialDiffOp;
|
||||
sourceFingerprint?: string;
|
||||
targetFingerprint?: string;
|
||||
/** True for ops that overwrite or delete an existing target value. */
|
||||
destructive: boolean;
|
||||
}
|
||||
|
||||
export interface CredentialDiff {
|
||||
from: CredentialEndpoint;
|
||||
to: CredentialEndpoint;
|
||||
redacted: true;
|
||||
entries: CredentialDiffEntry[];
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface CredentialPolicy {
|
||||
/** Secret values are always redacted in output; here for spec completeness. */
|
||||
redactValues: true;
|
||||
/** Destructive changes require an approval before a write runs. */
|
||||
requireApprovalForDestructive: boolean;
|
||||
/** Keys matching these (glob-ish) names are never written. */
|
||||
denyKeys?: string[];
|
||||
}
|
||||
|
||||
export interface CredentialSyncPlan {
|
||||
type: "logicsrc.credential_sync_plan";
|
||||
id: string;
|
||||
from: CredentialEndpoint;
|
||||
to: CredentialEndpoint;
|
||||
policy: CredentialPolicy;
|
||||
changes: CredentialDiffEntry[];
|
||||
requiresApproval: boolean;
|
||||
/** A rollback plan references the run it reverses. */
|
||||
rollbackOfRunId?: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface CredentialApproval {
|
||||
type: "logicsrc.credential_approval";
|
||||
id: string;
|
||||
planId: string;
|
||||
approver: LogicSrcPrincipal;
|
||||
/** Keys explicitly approved; empty means "all changes in the plan". */
|
||||
approvedKeys: string[];
|
||||
approvedAt: string;
|
||||
}
|
||||
|
||||
export type CredentialSyncRunStatus = "planned" | "dry_run" | "applied" | "partial" | "failed" | "rolled_back";
|
||||
|
||||
export interface CredentialKeyResult {
|
||||
key: string;
|
||||
op: CredentialDiffOp;
|
||||
applied: boolean;
|
||||
dryRun: boolean;
|
||||
targetFingerprint?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface CredentialSyncRun {
|
||||
type: "logicsrc.credential_sync_run";
|
||||
id: string;
|
||||
planId: string;
|
||||
status: CredentialSyncRunStatus;
|
||||
dryRun: boolean;
|
||||
results: CredentialKeyResult[];
|
||||
auditEventIds: string[];
|
||||
/** True when a rollback pre-image was captured for this run. */
|
||||
reversible: boolean;
|
||||
startedAt: string;
|
||||
finishedAt: string;
|
||||
}
|
||||
|
||||
export interface CredentialAuditEvent {
|
||||
type: "logicsrc.credential_audit_event";
|
||||
id: string;
|
||||
runId?: string;
|
||||
planId?: string;
|
||||
provider: CredentialProviderId;
|
||||
action: string;
|
||||
key: string;
|
||||
target: string;
|
||||
/** Fingerprint of the value written/observed — never the value itself. */
|
||||
fingerprint?: string;
|
||||
principal: LogicSrcPrincipal;
|
||||
decision: LogicSrcPolicyDecision;
|
||||
dryRun: boolean;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
/** Raw key/value bag — used only internally between adapters and the engine, never displayed. */
|
||||
export type CredentialValueBag = Record<string, string>;
|
||||
|
||||
export interface CredentialWriteResult {
|
||||
key: string;
|
||||
applied: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface CredentialProviderManifest {
|
||||
id: CredentialProviderId;
|
||||
name: string;
|
||||
description: string;
|
||||
capabilities: CredentialProviderCapabilities;
|
||||
/** Environment variables / fields the adapter needs to authenticate. */
|
||||
authRequirements: string[];
|
||||
status: "available" | "planned";
|
||||
}
|
||||
|
||||
/**
|
||||
* Provider adapter contract — the LogicSRC credential provider boundary.
|
||||
* Adapters are pure I/O: they read/write a backend and never decide policy.
|
||||
*/
|
||||
export interface CredentialProvider extends CredentialProviderManifest {
|
||||
/** Enumerate keys (names always; values only when capabilities.readValues). */
|
||||
inspect(endpoint: CredentialEndpoint): Promise<CredentialSnapshot>;
|
||||
/** Read raw values for the given keys (internal use only; gated by readValues). */
|
||||
readValues?(endpoint: CredentialEndpoint, keys: string[]): Promise<CredentialValueBag>;
|
||||
/** Apply a set of key writes/deletes. `dryRun` must short-circuit all mutation. */
|
||||
write(input: {
|
||||
endpoint: CredentialEndpoint;
|
||||
upserts: CredentialValueBag;
|
||||
deletes: string[];
|
||||
dryRun: boolean;
|
||||
}): Promise<CredentialWriteResult[]>;
|
||||
/** Restore a previously captured pre-image (optional; gated by rollback). */
|
||||
rollback?(input: { endpoint: CredentialEndpoint; preImage: CredentialValueBag; dryRun: boolean }): Promise<CredentialWriteResult[]>;
|
||||
/** Native audit trail (optional; gated by audit). */
|
||||
audit?(endpoint: CredentialEndpoint): Promise<CredentialAuditEvent[]>;
|
||||
}
|
||||
8
plugins/credential-sharing/tsconfig.json
Normal file
8
plugins/credential-sharing/tsconfig.json
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue