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
76 changes: 76 additions & 0 deletions docs/audit-storage-migration-compatibility.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
# Audit storage & migration compatibility (Issue #1679)

Area: authorization / resilience — privileged workflows, audit views, errors, and
degraded-mode behavior for the `meridian-api` administrative surface.

This change guarantees that the storage backing every privileged/operational
workflow (the RBAC guard, admin controllers, and the audit review views) is
**deterministically compatible across schema upgrades, rollbacks, repeats, and
storage failures**.

## Design & invariants

The contract lives in `src/audit/audit-storage.compatibility.ts` and is enforced
by the guard (`src/auth/guard/rbac/rbac.guard.ts`), the audit writer
(`src/audit/audit.service.ts`), and a new migration
(`src/database/migrations/1787400000000-audit-storage-compat.ts`).

| # | Invariant | Enforcement |
|---|-----------|-------------|
| 1 | **Forward compatibility** — new writers never emit rows unreadable by an older reader | `assertWriteBackwardCompatible()` rejects writes that exceed the column length bounds older readers assume; every new row is stamped `schemaVersion = CURRENT_SCHEMA_VERSION` (4). |
| 2 | **Backward compatibility** — rows written by older versions stay readable | `normalizeLegacyAuditRow()` fills safe defaults for columns introduced after a row was written (`correlationId`, `chainHash`, `contributionXp`, `epochNumber`, `schemaVersion`, …). Unknown future enum `action` values degrade to `UNKNOWN_ACTION` instead of crashing the reader. |
| 3 | **Resumable & observable migrations** | The migration uses only `IF NOT EXISTS` / `IF EXISTS` DDL and records a single-row `audit_storage_checkpoint` (phase + timestamp), surfacing progress via `RAISE NOTICE`. A partial run resumes cleanly on rerun. |
| 4 | **No partial/unauthorized state** | Repeated or replayed authorization decisions share an identity key and are de-duplicated within a 5s window, so a retry cannot leave duplicate audit rows. A failed audit write degrades to a structured `audit.degraded_mode` marker while the authorization decision remains authoritative. |

### Schema version timeline (`audit_logs`)
- `1` — base table (issue #632 audit foundation)
- `2` — `+ correlationId` (migration `1787200000000`)
- `3` — `+ AUTHORIZATION_GRANTED` / `AUTHORIZATION_DENIED` enum values (`1787300000000`)
- `4` — `+ schemaVersion` column + `audit_storage_checkpoint` table (this work)

Legacy rows (no `schemaVersion` column) are normalized to `null` and treated as
readable by `isSchemaCompatible(null) === true`.

## Failure behavior & compatibility impact

- **Audit store unavailable / slow:** the authorization decision is unaffected.
A `ForbiddenException`/`UnauthorizedException` is still thrown/allowed, the
audit write is attempted once, and on failure a `audit.degraded_mode` +
`audit.write_failed` structured log pair is emitted for operator diagnosis.
- **Oversized audit field:** the write is skipped with an `audit.write_compat_skipped`
warning rather than producing a row that older readers cannot parse. The
decision still proceeds.
- **Replay / duplicate request:** collapsed to a single audit record (idempotency).
- **Rejected/stale request:** no audit row is emitted before authentication
succeeds, so a rejected token leaves no audit or partial state.

## Migration / rollback considerations

- Deploy the migration (`npm run migration:run`) **before** deploying this code
so the `schemaVersion` column exists when writers start stamping it. Because
all DDL is idempotent, running the migration twice (or after a partial
failure) is safe.
- Rollback: `npm run migration:revert` removes the `schemaVersion` column and
the `audit_storage_checkpoint` table. It does **not** delete any pre-existing
audit records. Older code that ignores the column continues to work.
- The new `enum` values added in `1787300000000` are intentionally not removed
on down (PostgreSQL cannot drop enum values without a column rebuild); they
are harmless if left in place.

## Operational limitations

- The audit write remains **best-effort**: a sustained audit-store outage means
privileged actions still succeed but are not recorded until the store
recovers. The `audit.degraded_mode` marker is the operator signal for this gap.
- In-process de-duplication is per-instance and not shared across horizontally
scaled replicas; it bounds duplicate noise within a single instance's 5s window,
not globally. Global de-duplication would require a shared store (out of scope).

## Security assumptions

- Audit records are non-authoritative for access control; the RBAC decision is
computed from the JWT claims and never depends on audit storage being healthy.
- `correlationId` is treated as operator-provided untrusted input for length
bounding only; it is never used to relax authorization.
- Normalization never elevates privilege: unknown `action` values are surfaced
for review, never mapped to a granted/denied authorization outcome.
129 changes: 64 additions & 65 deletions meridian-api/jest.setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,35 @@ jest.mock(
{ virtual: true },
);

// ----- Auth metadata decorators (aliased path style) -----
// Controllers import these via `src/...`; replicate their real SetMetadata
// behavior so Reflector-based metadata assertions (protected-endpoints.spec)
// resolve and work without a global `src/` moduleNameMapper.
const rbacSetMetadata = jest.requireActual('@nestjs/common').SetMetadata;
jest.mock(
'src/auth/decorators/roles/roles.decorator',
() => ({
RequireRoles: (...roles: unknown[]) =>
rbacSetMetadata('requiredRoles', roles),
}),
{ virtual: true },
);
jest.mock(
'src/auth/decorators/permissions/permissions.decorator',
() => ({
RequirePermissions: (...permissions: unknown[]) =>
rbacSetMetadata('requiredPermissions', permissions),
}),
{ virtual: true },
);
jest.mock(
'src/auth/decorators/public/public.decorator',
() => ({
Public: () => rbacSetMetadata('isPublic', true),
}),
{ virtual: true },
);

// ----- Auth providers (idempotent stubs; per-spec files override as needed) -----
jest.mock(
'src/auth/providers/hashing',
Expand Down Expand Up @@ -104,51 +133,33 @@ jest.mock(
);

// ----- Entities (aliased paths) -----
jest.mock(
'src/users/user.entity',
() => ({ User: class User {} }),
{ virtual: true },
);
jest.mock(
'src/post/post.entity',
() => ({ Post: class Post {} }),
{ virtual: true },
);
jest.mock(
'src/tweets/dto/tweet.entity',
() => ({ Tweet: class Tweet {} }),
{ virtual: true },
);
jest.mock('src/users/user.entity', () => ({ User: class User {} }), {
virtual: true,
});
jest.mock('src/post/post.entity', () => ({ Post: class Post {} }), {
virtual: true,
});
jest.mock('src/tweets/dto/tweet.entity', () => ({ Tweet: class Tweet {} }), {
virtual: true,
});
jest.mock(
'src/tweets/entities/tweet.entity',
() => ({ Tweet: class Tweet {} }),
{ virtual: true },
);
jest.mock(
'src/tag/tag.entity',
() => ({ Tag: class Tag {} }),
{ virtual: true },
);
jest.mock(
'src/metaoption/metaoption.entity',
() => ({}),
{ virtual: true },
);
jest.mock(
'src/metaoption/dto/create-post-meta-options.dto',
() => ({}),
{ virtual: true },
);
jest.mock(
'src/metaoption/dto/update-post-meta-options.dto',
() => ({}),
{ virtual: true },
);
jest.mock(
'src/metaoption/metaoption.controller',
() => ({}),
{ virtual: true },
);
jest.mock('src/tag/tag.entity', () => ({ Tag: class Tag {} }), {
virtual: true,
});
jest.mock('src/metaoption/metaoption.entity', () => ({}), { virtual: true });
jest.mock('src/metaoption/dto/create-post-meta-options.dto', () => ({}), {
virtual: true,
});
jest.mock('src/metaoption/dto/update-post-meta-options.dto', () => ({}), {
virtual: true,
});
jest.mock('src/metaoption/metaoption.controller', () => ({}), {
virtual: true,
});

// ----- Services referenced through aliased paths -----
jest.mock(
Expand Down Expand Up @@ -202,19 +213,15 @@ jest.mock(
() => ({ PatchPostDto: class PatchPostDto {} }),
{ virtual: true },
);
jest.mock(
'src/DTO/getPostdto',
() => ({ GetPostsDto: class GetPostsDto {} }),
{ virtual: true },
);
jest.mock('src/DTO/getPostdto', () => ({ GetPostsDto: class GetPostsDto {} }), {
virtual: true,
});
jest.mock('src/DTO/signin-dto', () => ({}), { virtual: true });

// ----- Relative paths used by the spec files -----
jest.mock(
'../users/user.entity',
() => ({ User: class User {} }),
{ virtual: true },
);
jest.mock('../users/user.entity', () => ({ User: class User {} }), {
virtual: true,
});
jest.mock(
'../users/providers/user.services',
() => ({ UserService: class UserService {} }),
Expand All @@ -230,11 +237,9 @@ jest.mock(
() => ({ AuthService: class AuthService {} }),
{ virtual: true },
);
jest.mock(
'../post/post.entity',
() => ({ Post: class Post {} }),
{ virtual: true },
);
jest.mock('../post/post.entity', () => ({ Post: class Post {} }), {
virtual: true,
});
jest.mock(
'../post/provider/post.service',
() => ({ PostsService: class PostsService {} }),
Expand Down Expand Up @@ -266,11 +271,7 @@ jest.mock(
() => ({ UserService: class UserService {} }),
{ virtual: true },
);
jest.mock(
'./dtos/createManyUserdto',
() => ({}),
{ virtual: true },
);
jest.mock('./dtos/createManyUserdto', () => ({}), { virtual: true });
jest.mock('./dto/tweet.entity', () => ({ Tweet: class Tweet {} }), {
virtual: true,
});
Expand Down Expand Up @@ -372,8 +373,6 @@ jest.mock(
}),
{ virtual: true },
);
jest.mock(
'src/auth/enums/role-permissions',
() => ({ ROLE_PERMISSIONS: {} }),
{ virtual: true },
);
jest.mock('src/auth/enums/role-permissions', () => ({ ROLE_PERMISSIONS: {} }), {
virtual: true,
});
17 changes: 17 additions & 0 deletions meridian-api/src/audit/audit-log.entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,13 @@ export enum AuditAction {
CONTRACT_EVENT = 'CONTRACT_EVENT',
AUTHORIZATION_GRANTED = 'AUTHORIZATION_GRANTED',
AUTHORIZATION_DENIED = 'AUTHORIZATION_DENIED',
SIGN_IN = 'SIGN_IN',
REFRESH = 'REFRESH',
LOGOUT = 'LOGOUT',
LOGOUT_ALL = 'LOGOUT_ALL',
VERIFY_EMAIL = 'VERIFY_EMAIL',
ISSUE_VERIFICATION_TOKEN = 'ISSUE_VERIFICATION_TOKEN',
RESEND_VERIFICATION = 'RESEND_VERIFICATION',
}

@Entity('audit_logs')
Expand Down Expand Up @@ -89,4 +96,14 @@ export class AuditLog {
@Column({ type: 'varchar', length: 64, nullable: true })
@Index()
correlationId: string | null;

/**
* Compatibility marker (issue #1679). Tags each row with the schema version
* that produced it so readers can negotiate forward/backward compatibility
* and migrations stay resumable. Legacy rows written before this column
* existed are normalized to `null` by readers and treated as readable.
*/
@Column({ type: 'int', nullable: true })
@Index()
schemaVersion: number | null;
}
Loading
Loading