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
4 changes: 2 additions & 2 deletions docs/audit-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,13 +122,13 @@ Retention is configurable per organization.

| Setting | Default | Minimum | Maximum |
|--------------------|---------|---------|----------|
| `retentionDays` | 90 | 7 | 365 |
| `retentionDays` | 2 | 1 | 365 |

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: The repository now exposes conflicting retention contracts: this design document says 2/1, but specs/audit.md and the retention workflow still require 90/7. Updating the canonical spec and generated workflow (or clearly marking them obsolete) would prevent future implementations and verification from restoring the old behavior.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At docs/audit-design.md, line 125:

<comment>The repository now exposes conflicting retention contracts: this design document says 2/1, but `specs/audit.md` and the retention workflow still require 90/7. Updating the canonical spec and generated workflow (or clearly marking them obsolete) would prevent future implementations and verification from restoring the old behavior.</comment>

<file context>
@@ -122,13 +122,13 @@ Retention is configurable per organization.
 | Setting            | Default | Minimum | Maximum  |
 |--------------------|---------|---------|----------|
-| `retentionDays`    | 90      | 7       | 365      |
+| `retentionDays`    | 2       | 1       | 365      |
 
 ### Configuration
</file context>


### Configuration

```typescript
interface OrgAuditConfig {
retentionDays: number; // Default: 90
retentionDays: number; // Default: 2
webhookUrl?: string; // Optional webhook endpoint
webhookEvents?: AuditAction[]; // Filter which actions trigger webhooks (default: all)
webhookSecret?: string; // HMAC-SHA256 signing secret for webhook payloads
Expand Down
72 changes: 72 additions & 0 deletions packages/server/src/__tests__/audit-retention-migration.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import { DatabaseSync } from "node:sqlite";
import test from "node:test";

import {
createNodeSqliteRunner,
runMigrations,
sha256,
type MigrationSource,
} from "@relayauth/migrate";

const MIGRATION_ID = "0006_audit_retention_default";
const MIGRATION_URL = new URL(
`../db/migrations/${MIGRATION_ID}.sql`,
import.meta.url,
);

test("audit retention default migration preserves explicit overrides", async (t) => {
const db = new DatabaseSync(":memory:");
t.after(() => db.close());

db.exec(`
CREATE TABLE audit_retention_config (
org_id TEXT PRIMARY KEY,
retention_days INTEGER NOT NULL DEFAULT 90
);
INSERT INTO audit_retention_config (org_id, retention_days)
VALUES ('org_override', 30);
`);

const sql = await readFile(MIGRATION_URL, "utf8");
const source: MigrationSource = {
async list() {
return [{ id: MIGRATION_ID, sql, checksum: sha256(sql) }];
},
};
const result = await runMigrations(createNodeSqliteRunner(db), source);

assert.deepEqual(result, { applied: [MIGRATION_ID], skipped: [] });
assert.deepEqual(
db
.prepare(
"SELECT org_id, retention_days FROM audit_retention_config ORDER BY org_id",
)
.all()
.map((row) => ({
org_id: row.org_id,
retention_days: row.retention_days,
})),
[{ org_id: "org_override", retention_days: 30 }],
);

db.prepare("INSERT INTO audit_retention_config (org_id) VALUES (?)").run(
"org_default",
);
assert.deepEqual(
db
.prepare(
"SELECT org_id, retention_days FROM audit_retention_config ORDER BY org_id",
)
.all()
.map((row) => ({
org_id: row.org_id,
retention_days: row.retention_days,
})),
[
{ org_id: "org_default", retention_days: 2 },
{ org_id: "org_override", retention_days: 30 },
],
);
});
21 changes: 13 additions & 8 deletions packages/server/src/__tests__/audit-retention.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -398,12 +398,12 @@ test("purgeExpiredEntries deletes entries older than the provided retentionDays"
);
});

test("default retention is 90 days", async () => {
test("default retention is 2 days", async () => {
const retention = await loadAuditRetention();
const { db } = createRetentionD1({
logs: [
createAuditLog("aud_expired_120", "org_default", 120),
createAuditLog("aud_recent_30", "org_default", 30),
createAuditLog("aud_expired_3", "org_default", 3),
createAuditLog("aud_recent_1", "org_default", 1),
],
});

Expand All @@ -421,7 +421,7 @@ test("per-org retention override is respected", async () => {
const fallback = await retention.getRetentionConfig(db, "org_default");

assertRetentionConfig(override, "org_override", 30);
assertRetentionConfig(fallback, "org_default", 90);
assertRetentionConfig(fallback, "org_default", 2);
});

test("purgeExpiredEntries returns count of deleted entries", async () => {
Expand All @@ -447,7 +447,7 @@ test("getRetentionConfig returns org-specific or default config", async () => {
});

assertRetentionConfig(await retention.getRetentionConfig(db, "org_custom"), "org_custom", 120);
assertRetentionConfig(await retention.getRetentionConfig(db, "org_fallback"), "org_fallback", 90);
assertRetentionConfig(await retention.getRetentionConfig(db, "org_fallback"), "org_fallback", 2);
});

test("setRetentionConfig updates org retention setting", async () => {
Expand All @@ -461,13 +461,18 @@ test("setRetentionConfig updates org retention setting", async () => {
assertRetentionConfig(await retention.getRetentionConfig(db, "org_test"), "org_test", 180);
});

test("retention minimum is 7 days and lower values are rejected", async () => {
test("retention minimum is 1 day and lower values are rejected", async () => {
const retention = await loadAuditRetention();
const { db } = createRetentionD1();

await retention.setRetentionConfig(db, "org_test", 1);
assertRetentionConfig(await retention.getRetentionConfig(db, "org_test"), "org_test", 1);

await assert.rejects(
async () => retention.setRetentionConfig(db, "org_test", 6),
/7|minimum|retention/i,
async () => retention.setRetentionConfig(db, "org_test", 0),
(error: unknown) =>
error instanceof Error &&
error.message === "retentionDays must be at least 1 day",
);
});

Expand Down
14 changes: 7 additions & 7 deletions packages/server/src/__tests__/retention-gc.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -172,22 +172,22 @@ test("pruneExpiredTokens bounds a batch and preserves verifier clock skew", asyn
]);
});

test("audit retention uses overrides, a 90-day default, and bounded batches", async (t) => {
test("audit retention uses overrides, a 2-day default, and bounded batches", async (t) => {
const { storage, db } = createStorage(t);

await storage.DB.prepare(
"INSERT INTO audit_retention_config (org_id, retention_days) VALUES (?, ?), (?, ?), (?, ?), (?, ?)",
)
.bind("org_short", 30, "org_long", 180, "org_invalid", 1, "org_fractional", 30.5)
.bind("org_short", 30, "org_long", 180, "org_invalid", 0, "org_fractional", 30.5)
.run();

await insertAuditLog(storage, "aud_default_old", "org_default", daysBeforeNow(100));
await insertAuditLog(storage, "aud_default_recent", "org_default", daysBeforeNow(80));
await insertAuditLog(storage, "aud_default_old", "org_default", daysBeforeNow(3));
await insertAuditLog(storage, "aud_default_recent", "org_default", daysBeforeNow(1));
await insertAuditLog(storage, "aud_short_old", "org_short", daysBeforeNow(40));
await insertAuditLog(storage, "aud_short_recent", "org_short", daysBeforeNow(20));
await insertAuditLog(storage, "aud_long_recent", "org_long", daysBeforeNow(100));
await insertAuditLog(storage, "aud_invalid_old", "org_invalid", daysBeforeNow(100));
await insertAuditLog(storage, "aud_fractional_recent", "org_fractional", daysBeforeNow(40));
await insertAuditLog(storage, "aud_invalid_old", "org_invalid", daysBeforeNow(3));
await insertAuditLog(storage, "aud_fractional_recent", "org_fractional", daysBeforeNow(1));

assert.deepEqual(await countExpiredEntriesBatch(db, { now: NOW, limit: 2 }), {
expiredCount: 2,
Expand Down Expand Up @@ -266,7 +266,7 @@ test("audit cursor windows apply config/default retention inside a bounded rowid
await storage.DB.prepare(
"INSERT INTO audit_retention_config (org_id, retention_days) VALUES (?, ?), (?, ?), (?, ?)",
)
.bind("org_short", 30, "org_long", 180, "org_invalid", 1)
.bind("org_short", 30, "org_long", 180, "org_invalid", 0)
.run();

await insertAuditLog(storage, "aud_default_old", "org_default", daysBeforeNow(100), 3);
Expand Down
18 changes: 18 additions & 0 deletions packages/server/src/__tests__/storage-sqlite.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -800,3 +800,21 @@ test("TestSqliteAutoCreateTables", async (t) => {
);
}
});

test("TestSqliteAuditRetentionDefaultsToTwoDays", async (t) => {
const { storage } = createTempStorage(t);

await storage.DB.prepare(
"INSERT INTO audit_retention_config (org_id) VALUES (?)",
)
.bind("org_default_retention")
.run();

const row = await storage.DB.prepare(
"SELECT retention_days FROM audit_retention_config WHERE org_id = ?",
)
.bind("org_default_retention")
.first<{ retention_days?: number }>();

assert.equal(row?.retention_days, 2);
});
15 changes: 15 additions & 0 deletions packages/server/src/db/migrations/0006_audit_retention_default.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
-- Keep audit history intentionally short unless an organization explicitly
-- opts into a longer window. SQLite cannot alter a column default in place,
-- so rebuild this small configuration table while preserving every override.
CREATE TABLE audit_retention_config_v2 (
org_id TEXT PRIMARY KEY,
retention_days INTEGER NOT NULL DEFAULT 2
);

INSERT INTO audit_retention_config_v2 (org_id, retention_days)
SELECT org_id, retention_days
FROM audit_retention_config;

DROP TABLE audit_retention_config;

ALTER TABLE audit_retention_config_v2 RENAME TO audit_retention_config;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
12 changes: 9 additions & 3 deletions packages/server/src/engine/audit-retention.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,8 @@ type RetentionConfigRow = {
retentionDays?: unknown;
};

const DEFAULT_RETENTION_DAYS = 90;
const MIN_RETENTION_DAYS = 7;
const DEFAULT_RETENTION_DAYS = 2;
const MIN_RETENTION_DAYS = 1;
const MAX_RETENTION_DAYS = 365;

export async function purgeExpiredEntries(
Expand Down Expand Up @@ -120,7 +120,9 @@ function normalizeRetentionDays(value: unknown): number {
const num = value;

if (num < MIN_RETENTION_DAYS) {
throw new Error(`retentionDays must be at least ${MIN_RETENTION_DAYS} days`);
throw new Error(
`retentionDays must be at least ${MIN_RETENTION_DAYS} ${formatDays(MIN_RETENTION_DAYS)}`,
);
}

if (num > MAX_RETENTION_DAYS) {
Expand All @@ -130,6 +132,10 @@ function normalizeRetentionDays(value: unknown): number {
return num;
}

function formatDays(value: number): "day" | "days" {
return value === 1 ? "day" : "days";
}

function createCutoffTimestamp(retentionDays: number): string {
return new Date(Date.now() - retentionDays * 24 * 60 * 60 * 1000).toISOString();
}
Expand Down
2 changes: 1 addition & 1 deletion packages/server/src/engine/retention-gc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -369,7 +369,7 @@ export async function countExpiredTokensBatch(

/**
* Deletes one bounded batch of audit logs using each organization's configured
* retention period, or the 90-day default when the config row is absent.
* retention period, or the two-day default when the config row is absent.
*/
export async function purgeExpiredEntriesBatch(
db: RetentionGcSqlExecutor,
Expand Down
4 changes: 2 additions & 2 deletions specs/audit.md
Original file line number Diff line number Diff line change
Expand Up @@ -549,8 +549,8 @@ interface OrgAuditRetentionConfig {

Rules:

- Default `retentionDays` is `90`.
- Minimum supported value is `7`.
- Default `retentionDays` is `2`.
- Minimum supported value is `1`.
- Maximum supported value is `365`.
- Values outside this range must be rejected at write time.
- Retention applies to all audit actions unless future product policy adds
Expand Down
2 changes: 1 addition & 1 deletion workflows/006-audit-spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ Write design outline to ${ROOT}/docs/audit-design.md covering:
- Audit entry format: all fields from AuditEntry
- Actions: all AuditAction values and when they fire
- Storage: D1 table schema, indexes
- Retention: configurable per-org, default 90 days
- Retention: configurable per-org, default 2 days, minimum 1 day
- Query semantics: filters, pagination, sorting
- Export: CSV and JSON formats
- Webhook notifications on audit events`,
Expand Down
8 changes: 4 additions & 4 deletions workflows/054-audit-retention.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,12 +92,12 @@ Use node:test + node:assert/strict. Import helpers from ./test-helpers.js.

Test these behaviors:
1. purgeExpiredEntries(db, retentionDays) deletes entries older than retentionDays
2. Default retention is 90 days
2. Default retention is 2 days
3. Per-org retention override is respected
4. purgeExpiredEntries returns count of deleted entries
5. getRetentionConfig(db, orgId) returns org-specific or default config
6. setRetentionConfig(db, orgId, days) updates org retention setting
7. Retention minimum is 7 days (rejects lower values)
7. Retention minimum is 1 day (rejects lower values)
8. Dry run mode: countExpiredEntries() returns count without deleting`,
verification: { type: 'exit_code' },
})
Expand Down Expand Up @@ -133,8 +133,8 @@ Write to ${ROOT}/packages/server/src/engine/audit-retention.ts:
2. countExpiredEntries(db, retentionDays?) — COUNT without deleting (dry run)
3. getRetentionConfig(db, orgId) — read from audit_retention_config table
4. setRetentionConfig(db, orgId, days) — upsert retention config
5. Default retention: 90 days
6. Minimum retention: 7 days (throw if lower)
5. Default retention: 2 days
6. Minimum retention: 1 day (throw if lower)
7. Return { deletedCount } from purge

Export from the package.`,
Expand Down
Loading