fix(secrets): audit low-trust user-secret omissions
Record a value-free binding_not_allowed access event when an inherited user-secret declaration is omitted, and classify company-secret denials consistently. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
1fde51483e
commit
a25aff0516
|
|
@ -1558,6 +1558,16 @@ describeEmbeddedPostgres("secretService", () => {
|
|||
expect(resolved.manifest).toEqual([]);
|
||||
expect(JSON.stringify(resolved)).not.toContain("aral-bp-invoices");
|
||||
|
||||
const [denialEvent] = await svc.listAccessEvents(companyId, secret.id);
|
||||
expect(denialEvent).toMatchObject({
|
||||
consumerType: "project",
|
||||
consumerId: "project-1",
|
||||
configPath: "env.ARAL_BP_RECHNUNGEN",
|
||||
outcome: "failure",
|
||||
errorCode: "binding_not_allowed",
|
||||
});
|
||||
expect(JSON.stringify(denialEvent)).not.toContain("aral-bp-invoices");
|
||||
|
||||
// Without the opt-in the same call still hard-fails — omission is
|
||||
// per-call, not a change to the default enforcement.
|
||||
await expect(
|
||||
|
|
@ -1666,7 +1676,7 @@ describeEmbeddedPostgres("secretService", () => {
|
|||
const companyId = await seedCompany();
|
||||
await seedCompanyMember(companyId, "user-1", "owner");
|
||||
const svc = secretService(db);
|
||||
await svc.createUserSecretDefinition(companyId, {
|
||||
const definition = await svc.createUserSecretDefinition(companyId, {
|
||||
key: "github_token",
|
||||
name: "GitHub token",
|
||||
provider: "local_encrypted",
|
||||
|
|
@ -1698,6 +1708,28 @@ describeEmbeddedPostgres("secretService", () => {
|
|||
expect(resolved.env).toEqual({ ROUTINE_PLAIN: "still-here" });
|
||||
expect(resolved.secretKeys.has("GITHUB_TOKEN")).toBe(false);
|
||||
expect(JSON.stringify(resolved)).not.toContain("user-one-secret");
|
||||
|
||||
const [denialEvent] = await db
|
||||
.select()
|
||||
.from(secretAccessEvents)
|
||||
.where(and(
|
||||
eq(secretAccessEvents.companyId, companyId),
|
||||
eq(secretAccessEvents.userSecretDefinitionId, definition.id),
|
||||
));
|
||||
expect(denialEvent).toMatchObject({
|
||||
secretId: null,
|
||||
secretScope: "user",
|
||||
responsibleUserId: "user-1",
|
||||
credentialOwnerUserId: "user-1",
|
||||
credentialSubjectType: "user",
|
||||
credentialSubjectId: "user-1",
|
||||
consumerType: "routine",
|
||||
consumerId: "routine-1",
|
||||
configPath: "env.GITHUB_TOKEN",
|
||||
outcome: "failure",
|
||||
errorCode: "binding_not_allowed",
|
||||
});
|
||||
expect(JSON.stringify(denialEvent)).not.toContain("user-one-secret");
|
||||
});
|
||||
|
||||
it("resolves routine env secret refs through routine bindings and records value-free access metadata", async () => {
|
||||
|
|
|
|||
|
|
@ -712,6 +712,7 @@ type RuntimeSecretResolution = {
|
|||
};
|
||||
|
||||
type SecretResolutionErrorCode =
|
||||
| "binding_not_allowed"
|
||||
| "binding_missing"
|
||||
| "secret_deleted"
|
||||
| "secret_inactive"
|
||||
|
|
@ -822,6 +823,7 @@ function secretResolutionErrorCode(error: unknown): SecretResolutionErrorCode {
|
|||
if (error instanceof HttpError) {
|
||||
const details = asRecord(error.details);
|
||||
switch (details?.code) {
|
||||
case "binding_not_allowed":
|
||||
case "binding_missing":
|
||||
case "secret_deleted":
|
||||
case "secret_inactive":
|
||||
|
|
@ -1155,7 +1157,7 @@ export function secretService(db: Db | DbTransaction) {
|
|||
|
||||
async function recordAccessEvent(input: {
|
||||
companyId: string;
|
||||
secretId: string;
|
||||
secretId: string | null;
|
||||
userSecretDefinitionId?: string | null;
|
||||
secretScope?: string | null;
|
||||
version: number | null;
|
||||
|
|
@ -4166,6 +4168,20 @@ export function secretService(db: Db | DbTransaction) {
|
|||
Array.isArray(context?.allowedBindingIds) &&
|
||||
(!declaration || !context.allowedBindingIds.includes(declaration.id))
|
||||
) {
|
||||
await recordAccessEvent({
|
||||
companyId,
|
||||
secretId: null,
|
||||
userSecretDefinitionId: definition.id,
|
||||
secretScope: "user",
|
||||
version: null,
|
||||
provider: definition.provider as SecretProvider,
|
||||
context: context ? { ...context, responsibleUserId } : undefined,
|
||||
credentialOwnerUserId: responsibleUserId,
|
||||
credentialSubjectType: "user",
|
||||
credentialSubjectId: responsibleUserId,
|
||||
outcome: "failure",
|
||||
errorCode: "binding_not_allowed",
|
||||
}).catch(() => undefined);
|
||||
throw unprocessable(
|
||||
"User secret declaration is outside the active low-trust boundary",
|
||||
{ code: "binding_not_allowed" },
|
||||
|
|
|
|||
Loading…
Reference in New Issue