Skip to content
Closed
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
54 changes: 54 additions & 0 deletions agents/examples/TENANT_STATUS_HARDENING_20260206.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
{
"task_id": "TENANT_STATUS_HARDENING_20260206",
"agent_id": "codex",
"prompt_ref": {
"id": "tenant-status-hardening",
"version": "v1",
"sha256": "308cbe897064118c0d844b505affe31fae71cd01905d7010ec07da75deac73fe",
"path": "prompts/tenants/tenant-status-hardening@v1.md"
},
"declared_scope": {
"paths": [
"server/src/routes/tenants.ts",
"server/src/services/TenantService.ts",
"server/src/routes/__tests__/tenants.test.ts",
"docs/roadmap/STATUS.json",
"prompts/tenants/tenant-status-hardening@v1.md",
"prompts/registry.yaml",
"agents/examples/TENANT_STATUS_HARDENING_20260206.json"
],
"domains": [
"server",
"governance",
"tenancy",
"compliance"
]
},
"allowed_operations": [
"create",
"edit"
],
"verification_requirements": {
"tier": "C",
"artifacts": [
"jest tenants.test.ts output",
"roadmap status update"
]
},
"debt_budget": {
"permitted": 0,
"retirement_target": 0
},
"success_criteria": [
"PATCH /api/tenants/{id} supports status active|suspended with safe transition constraints",
"Tenant status change emits provenance entry",
"Tenant export/delete requests enforce dual-control approvals before acceptance",
"Tenant route tests cover status and dual-control request flows",
"Sprint +3 initiative registered in roadmap status"
],
"stop_conditions": [
"Policy gate bypass required",
"Scope change outside declared paths",
"Missing provenance ledger integration"
]
}
14 changes: 10 additions & 4 deletions docs/roadmap/STATUS.json
Original file line number Diff line number Diff line change
@@ -1,13 +1,19 @@
{
"last_updated": "2026-02-06T19:29:02Z",
"revision_note": "Added CI/CD high-signal delta action register for SSDF, OSPS, SLSA, ISO 27001, and NIST supply-chain guidance.",
"last_updated": "2026-02-06T20:32:00Z",
"revision_note": "Expanded Sprint +3 hosted SaaS hardening with dual-control export/delete requests and safer tenant status transitions.",
"initiatives": [
{
"id": "adenhq-hive-subsumption-lane1",
"status": "in_progress",
"owner": "codex",
"notes": "Scaffold adenhq/hive subsumption bundle, required check mapping, and evidence-first lane-1 posture."
},
{
"id": "sprint-plus3-hosted-saas-hardening",
"status": "in_progress",
"owner": "codex",
"notes": "Sprint +3 hosted SaaS hardening: tenant status controls, dual-control export/delete requests, billing hooks, DR evidence, and compliance pack groundwork."
},
{
"id": "B",
"name": "Federation + Ingestion Mesh",
Expand Down Expand Up @@ -144,7 +150,7 @@
"id": "ip-claims-continuation-pack-c451-s480",
"status": "in_progress",
"owner": "codex",
"notes": "Added defense CRM and simulation apparatus dependent claims C451–C480 and S451–S480."
"notes": "Added defense CRM and simulation apparatus dependent claims C451\u2013C480 and S451\u2013S480."
},
{
"id": "io-cogwar-radar-2027-brief",
Expand All @@ -162,7 +168,7 @@
"id": "ip-defense-claims-c391-s420",
"status": "completed",
"owner": "codex",
"notes": "Added CRM and Simulation Apparatus claims C391–C420/S391–S420 for graph integrity, appeals, and causal guardrails."
"notes": "Added CRM and Simulation Apparatus claims C391\u2013C420/S391\u2013S420 for graph integrity, appeals, and causal guardrails."
},
{
"id": "spec-driven-development-docs",
Expand Down
28 changes: 28 additions & 0 deletions prompts/registry.yaml
Original file line number Diff line number Diff line change
@@ -1,5 +1,33 @@
version: 1
prompts:
- id: tenant-status-hardening
version: v1
path: prompts/tenants/tenant-status-hardening@v1.md
sha256: 308cbe897064118c0d844b505affe31fae71cd01905d7010ec07da75deac73fe
description: Add tenant status patch endpoint with provenance, tests, and Sprint +3 roadmap registration.
scope:
paths:
- server/src/routes/tenants.ts
- server/src/services/TenantService.ts
- server/src/routes/__tests__/tenants.test.ts
- docs/roadmap/STATUS.json
- prompts/tenants/tenant-status-hardening@v1.md
- prompts/registry.yaml
- agents/examples/TENANT_STATUS_HARDENING_20260206.json
domains:
- server
- governance
- tenancy
- compliance
verification:
tiers_required:
- C
debt_budget:
permitted: 0
retirement_target: 0
allowed_operations:
- create
- edit
- id: io-cogwar-radar-brief
version: v1
path: prompts/briefs/io-cogwar-radar-brief@v1.md
Expand Down
18 changes: 18 additions & 0 deletions prompts/tenants/tenant-status-hardening@v1.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Tenant status hardening (Sprint +3)

## Objective
Implement tenant status lifecycle controls for hosted SaaS readiness, including:

- PATCH /api/tenants/{id} to set status to active or suspended.
- Provenance entry and lifecycle history on status changes.
- Route test coverage for status changes and dual-control sensitive operation requests.
- Dual-control-gated tenant export/delete request endpoints.
- Update docs/roadmap/STATUS.json to register the Sprint +3 hardening initiative.

## Constraints
- Scope limited to tenant routes, tenant service, tests, and roadmap status.
- No policy bypasses; enforce existing ABAC policy gate.

## Evidence
- Jest route tests for tenant status patch and dual-control export/delete request workflows.
- Roadmap status update recorded.
147 changes: 95 additions & 52 deletions server/src/routes/__tests__/tenants.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
import { jest, describe, it, expect, beforeEach, afterEach, beforeAll, afterAll } from '@jest/globals';
import { jest, describe, it, expect, beforeEach } from '@jest/globals';
import request from 'supertest';
import express from 'express';

// Mock functions declared before mocks
const mockGetTenantSettings = jest.fn();
const mockUpdateSettings = jest.fn();
const mockDisableTenant = jest.fn();
Expand All @@ -12,6 +11,10 @@ const mockGetPostgresPool = jest.fn();
const mockGetRedisClient = jest.fn();
const mockAppendEntry = jest.fn();
const mockRepoBy = jest.fn();
const mockUpdateStatus = jest.fn();
const mockCreateSensitiveOperationRequest = jest.fn();
const mockNormalizeApprovalActors = jest.fn();
const mockValidateDualControlRequirement = jest.fn();
const mockEnsurePolicy = jest.fn((_action: string, _resource: string) => (_req: any, _res: any, next: any) => next());

let currentUser: any = {
Expand All @@ -20,24 +23,21 @@ let currentUser: any = {
tenantId: 'tenant-1',
};

// ESM-compatible mocking using unstable_mockModule
jest.unstable_mockModule('../../services/TenantService.js', () => ({
tenantService: {
getTenantSettings: mockGetTenantSettings,
updateSettings: mockUpdateSettings,
disableTenant: mockDisableTenant,
createTenant: mockCreateTenant,
updateStatus: mockUpdateStatus,
createSensitiveOperationRequest: mockCreateSensitiveOperationRequest,
},
createTenantSchema: {
parse: (v: any) => v,
extend: () => ({
parse: (v: any) => v,
}),
extend: () => ({ parse: (v: any) => v }),
},
createTenantBaseSchema: {
extend: () => ({
parse: (v: any) => v,
}),
extend: () => ({ parse: (v: any) => v }),
},
}));

Expand All @@ -63,6 +63,11 @@ jest.unstable_mockModule('../../middleware/abac.js', () => ({
ensurePolicy: mockEnsurePolicy,
}));

jest.unstable_mockModule('../../middleware/dual-control.js', () => ({
normalizeApprovalActors: mockNormalizeApprovalActors,
validateDualControlRequirement: mockValidateDualControlRequirement,
}));

jest.unstable_mockModule('../../provenance/ledger.js', () => ({
provenanceLedger: {
appendEntry: mockAppendEntry,
Expand All @@ -75,13 +80,10 @@ jest.unstable_mockModule('../../repos/ProvenanceRepo.js', () => ({
})),
}));

// Dynamic imports AFTER mocks are set up
const tenantsRouter = (await import('../tenants.js')).default;
const { ProvenanceRepo } = await import('../../repos/ProvenanceRepo.js');
const database = await import('../../config/database.js');

const describeIf =
process.env.NO_NETWORK_LISTEN === 'true' ? describe.skip : describe;
const describeIf = process.env.NO_NETWORK_LISTEN === 'true' ? describe.skip : describe;

describeIf('tenants routes', () => {
const app = express();
Expand All @@ -90,9 +92,7 @@ describeIf('tenants routes', () => {

beforeEach(() => {
jest.clearAllMocks();
(ProvenanceRepo as jest.Mock).mockImplementation(() => ({
by: mockRepoBy,
}));
(ProvenanceRepo as jest.Mock).mockImplementation(() => ({ by: mockRepoBy }));
mockGetPostgresPool.mockReturnValue({
connect: jest.fn().mockResolvedValue({
query: jest.fn().mockResolvedValue({ rows: [{ id: 'event-1' }] }),
Expand All @@ -101,47 +101,39 @@ describeIf('tenants routes', () => {
});
mockGetRedisClient.mockReturnValue(null);
currentUser = { id: 'user-1', role: 'admin', tenantId: 'tenant-1' };
mockGetTenantSettings.mockResolvedValue({
id: 'tenant-1',
settings: { theme: 'light' },
config: {},
status: 'active',
});
mockUpdateSettings.mockResolvedValue({
id: 'tenant-1',
settings: { theme: 'dark' },
config: {},
status: 'active',
});
mockDisableTenant.mockResolvedValue({
id: 'tenant-1',
status: 'disabled',
config: {},
settings: {},
});
mockCreateTenant.mockResolvedValue({
id: 'tenant-1',
name: 'Acme',
slug: 'acme',
residency: 'US',

mockGetTenantSettings.mockResolvedValue({ id: 'tenant-1', settings: { theme: 'light' }, config: {}, status: 'active' });
mockUpdateSettings.mockResolvedValue({ id: 'tenant-1', settings: { theme: 'dark' }, config: {}, status: 'active' });
mockDisableTenant.mockResolvedValue({ id: 'tenant-1', status: 'disabled', config: {}, settings: {} });
mockUpdateStatus.mockResolvedValue({ id: 'tenant-1', status: 'suspended', config: {}, settings: {} });
mockCreateTenant.mockResolvedValue({ id: 'tenant-1', name: 'Acme', slug: 'acme', residency: 'US' });
mockCreateSensitiveOperationRequest.mockResolvedValue({
id: 'req-1',
action: 'export',
tenantId: 'tenant-1',
reason: 'legal hold',
createdBy: 'user-1',
createdAt: '2026-02-06T20:00:00.000Z',
approvals: [
{ user_id: 'approver-1', role: 'compliance-officer' },
{ user_id: 'approver-2', role: 'security-admin' },
],
});

mockGetTenantUsage.mockResolvedValue({
tenantId: 'tenant-1',
range: { key: '7d', start: '2024-01-01T00:00:00.000Z', end: '2024-01-08T00:00:00.000Z' },
totals: [{ kind: 'external_api.requests', unit: 'requests', total: 3 }],
breakdown: {
byWorkflow: [{ workflow: 'ingest', totals: [{ kind: 'external_api.requests', unit: 'requests', total: 3 }] }],
byEnvironment: [{ environment: 'prod', totals: [{ kind: 'external_api.requests', unit: 'requests', total: 3 }] }],
byWorkflowEnvironment: [
{
workflow: 'ingest',
environment: 'prod',
totals: [{ kind: 'external_api.requests', unit: 'requests', total: 3 }],
},
],
byWorkflowEnvironment: [{ workflow: 'ingest', environment: 'prod', totals: [{ kind: 'external_api.requests', unit: 'requests', total: 3 }] }],
},
});

mockRepoBy.mockResolvedValue([{ id: 'event-1' }]);
mockNormalizeApprovalActors.mockImplementation((approvals: any) => approvals);
mockValidateDualControlRequirement.mockResolvedValue({ satisfied: true, violations: [], recordedApprovals: 2 });
});

it('returns settings with receipt', async () => {
Expand All @@ -153,24 +145,75 @@ describeIf('tenants routes', () => {
});

it('updates settings and issues receipt', async () => {
const res = await request(app)
.put('/api/tenants/tenant-1/settings')
.send({ settings: { theme: 'dark' } });
const res = await request(app).put('/api/tenants/tenant-1/settings').send({ settings: { theme: 'dark' } });
expect(res.status).toBe(200);
expect(res.body.data.settings.theme).toBe('dark');
expect(res.body.receipt.action).toBe('TENANT_SETTINGS_UPDATED');
expect(mockUpdateSettings).toHaveBeenCalledWith('tenant-1', { theme: 'dark' }, 'user-1');
});

it('disables tenant with receipt', async () => {
const res = await request(app)
.post('/api/tenants/tenant-1/disable')
.send({ reason: 'test' });
const res = await request(app).post('/api/tenants/tenant-1/disable').send({ reason: 'test' });
expect(res.status).toBe(200);
expect(res.body.data.status).toBe('disabled');
expect(res.body.receipt.action).toBe('TENANT_DISABLED');
});

it('updates tenant status with receipt', async () => {
const res = await request(app).patch('/api/tenants/tenant-1').send({ status: 'suspended', reason: 'maintenance' });
expect(res.status).toBe(200);
expect(res.body.data.status).toBe('suspended');
expect(res.body.receipt.action).toBe('TENANT_STATUS_UPDATED');
expect(mockUpdateStatus).toHaveBeenCalledWith('tenant-1', 'suspended', 'user-1', 'maintenance');
});

it('returns 409 for blocked transitions', async () => {
mockUpdateStatus.mockRejectedValueOnce(new Error('Disabled tenants require dedicated reactivation workflow'));
const res = await request(app).patch('/api/tenants/tenant-1').send({ status: 'active', reason: 'reactivate' });
expect(res.status).toBe(409);
});

it('creates export request when dual-control is satisfied', async () => {
const payload = {
reason: 'compliance export',
approvals: [
{ user_id: 'approver-1', role: 'compliance-officer' },
{ user_id: 'approver-2', role: 'security-admin' },
],
};
const res = await request(app).post('/api/tenants/tenant-1/export-requests').send(payload);
expect(res.status).toBe(202);
expect(mockValidateDualControlRequirement).toHaveBeenCalled();
expect(mockCreateSensitiveOperationRequest).toHaveBeenCalledWith(
'tenant-1',
'export',
'compliance export',
'user-1',
payload.approvals,
undefined,
);
});

it('rejects delete request when dual-control fails', async () => {
mockValidateDualControlRequirement.mockResolvedValueOnce({
satisfied: false,
violations: ['At least one compliance-officer approval is required'],
recordedApprovals: 1,
});

const res = await request(app).post('/api/tenants/tenant-1/delete-requests').send({
reason: 'gdpr erasure',
approvals: [
{ user_id: 'approver-1', role: 'admin' },
{ user_id: 'approver-2', role: 'admin' },
],
});

expect(res.status).toBe(403);
expect(mockCreateSensitiveOperationRequest).not.toHaveBeenCalled();
expect(res.body.error).toBe('Dual-control requirement not satisfied');
});

it('blocks cross-tenant access', async () => {
currentUser = { id: 'user-2', role: 'user', tenantId: 'other-tenant' } as any;
const res = await request(app).get('/api/tenants/tenant-1/settings');
Expand Down
Loading
Loading