feat(api): advance capability and secure operations slices
This commit is contained in:
@@ -228,6 +228,48 @@ export const MIGRATIONS: readonly Migration[] = [
|
||||
'CREATE INDEX idx_operation_preparations_status_expires_at ON operation_preparations(status, expires_at)',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
name: 'generic-secure-operation-preparation-binding',
|
||||
statements: [
|
||||
`CREATE TABLE operation_preparations_v5 (
|
||||
id TEXT PRIMARY KEY,
|
||||
operation_id TEXT NOT NULL,
|
||||
risk_level TEXT NOT NULL CHECK (risk_level IN ('R2','R3')),
|
||||
status TEXT NOT NULL CHECK (status IN ('prepared','consumed','expired','invalidated')),
|
||||
target_instance_id TEXT NOT NULL,
|
||||
target_revision INTEGER NOT NULL CHECK (target_revision > 0),
|
||||
target_origin TEXT NOT NULL,
|
||||
method TEXT NOT NULL,
|
||||
path TEXT NOT NULL,
|
||||
canonical_query TEXT NOT NULL,
|
||||
body_digest TEXT NOT NULL CHECK (length(body_digest) = 64),
|
||||
content_type TEXT NOT NULL,
|
||||
parameter_schema_id TEXT NOT NULL,
|
||||
parameters_digest TEXT NOT NULL CHECK (length(parameters_digest) = 64),
|
||||
nonce TEXT NOT NULL UNIQUE,
|
||||
token_digest TEXT NOT NULL CHECK (length(token_digest) = 64),
|
||||
requested_by TEXT NOT NULL,
|
||||
request_id TEXT NOT NULL,
|
||||
expires_at TEXT NOT NULL,
|
||||
consumed_at TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
)`,
|
||||
`INSERT INTO operation_preparations_v5
|
||||
(id,operation_id,risk_level,status,target_instance_id,target_revision,target_origin,method,path,
|
||||
canonical_query,body_digest,content_type,parameter_schema_id,parameters_digest,nonce,token_digest,
|
||||
requested_by,request_id,expires_at,consumed_at,created_at,updated_at)
|
||||
SELECT p.id,p.operation_id,p.risk_level,p.status,p.target_instance_id,p.target_revision,
|
||||
COALESCE(i.base_url,''),'DELETE','/api/v1/instances/' || p.target_instance_id,'',
|
||||
'${createHash('sha256').update('').digest('hex')}','',p.parameter_schema_id,p.parameters_digest,
|
||||
'legacy-' || p.id,p.token_digest,p.requested_by,p.request_id,p.expires_at,p.consumed_at,p.created_at,p.updated_at
|
||||
FROM operation_preparations p LEFT JOIN instances i ON i.id=p.target_instance_id`,
|
||||
'DROP TABLE operation_preparations',
|
||||
'ALTER TABLE operation_preparations_v5 RENAME TO operation_preparations',
|
||||
'CREATE INDEX idx_operation_preparations_status_expires_at ON operation_preparations(status, expires_at)',
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const createMigrationsTable = `CREATE TABLE schema_migrations (
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest';
|
||||
import { createSafeControlPlaneUpstream } from './safe-control-plane-upstream.js';
|
||||
|
||||
describe('createSafeControlPlaneUpstream', () => {
|
||||
it('routes both health GET and login POST through the same safe transport', async () => {
|
||||
it('routes health, login, and the audited R2 operation through the same safe transport', async () => {
|
||||
const calls: unknown[] = [];
|
||||
const upstream = createSafeControlPlaneUpstream({
|
||||
get: async (url) => {
|
||||
@@ -22,6 +22,7 @@ describe('createSafeControlPlaneUpstream', () => {
|
||||
secret: '[REDACTED]',
|
||||
body: '[REDACTED]',
|
||||
});
|
||||
await upstream.postNetworkRegisterAuto('http://192.168.1.20');
|
||||
expect(calls).toEqual([
|
||||
{ method: 'GET', url: 'http://192.168.1.20/api/health' },
|
||||
{
|
||||
@@ -30,6 +31,12 @@ describe('createSafeControlPlaneUpstream', () => {
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: '{"password":"[REDACTED]"}',
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
url: 'http://192.168.1.20/api/network/register-auto',
|
||||
headers: {},
|
||||
body: '',
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@ import { SafeUpstreamGateway, type SafeUpstreamTransport } from './safe-upstream
|
||||
|
||||
export interface SafeControlPlaneUpstream extends ConnectionTransport {
|
||||
request: UpstreamSessionClientOptions['request'];
|
||||
postNetworkRegisterAuto(origin: string): Promise<{ readonly status: number }>;
|
||||
}
|
||||
export function createSafeControlPlaneUpstream(
|
||||
transport: SafeUpstreamTransport,
|
||||
@@ -12,5 +13,6 @@ export function createSafeControlPlaneUpstream(
|
||||
return {
|
||||
get: (url) => transport.get(url),
|
||||
request: (request) => gateway.request(request),
|
||||
postNetworkRegisterAuto: (origin) => gateway.postNetworkRegisterAuto(origin),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,7 +1,35 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { SafeUpstreamGateway } from './safe-upstream-gateway.js';
|
||||
|
||||
describe('SafeUpstreamGateway', () => {
|
||||
it('dispatches only the audited zero-body network registration operation through pinned POST', async () => {
|
||||
const calls: unknown[] = [];
|
||||
const gateway = new SafeUpstreamGateway({
|
||||
transport: {
|
||||
get: async () => ({ status: 200, headers: {}, body: '' }),
|
||||
post: async (url, headers, body) => {
|
||||
calls.push({ url, headers, body });
|
||||
return { status: 204, headers: {}, body: '' };
|
||||
},
|
||||
},
|
||||
});
|
||||
const response = await gateway.postNetworkRegisterAuto('http://192.168.1.20:8080');
|
||||
expect(response.status).toBe(204);
|
||||
expect(calls).toEqual([
|
||||
{ url: 'http://192.168.1.20:8080/api/network/register-auto', headers: {}, body: '' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('rejects malformed operation origins before calling transport', async () => {
|
||||
const post = vi.fn(async () => ({ status: 204, headers: {}, body: '' }));
|
||||
const gateway = new SafeUpstreamGateway({
|
||||
transport: { get: async () => ({ status: 200, headers: {}, body: '' }), post },
|
||||
});
|
||||
await expect(
|
||||
gateway.postNetworkRegisterAuto('http://192.168.1.20/base?next=x'),
|
||||
).rejects.toMatchObject({ code: 'UPSTREAM_REQUEST_INVALID', dispatched: false });
|
||||
expect(post).not.toHaveBeenCalled();
|
||||
});
|
||||
it('sends login password only as JSON through the pinned POST transport', async () => {
|
||||
const calls: unknown[] = [];
|
||||
const gateway = new SafeUpstreamGateway({
|
||||
|
||||
@@ -4,6 +4,7 @@ import type {
|
||||
} from '../../application/connections/upstream-session-client.js';
|
||||
import type { TransportResponse } from './safe-instance-transport.js';
|
||||
import { UpstreamError } from './upstream-error.js';
|
||||
import { OperationNotDispatchedError } from '../../application/operations/secure-operation-execution.js';
|
||||
|
||||
export interface SafeUpstreamTransport {
|
||||
get(url: string): Promise<TransportResponse>;
|
||||
@@ -15,6 +16,34 @@ export interface SafeUpstreamTransport {
|
||||
}
|
||||
export class SafeUpstreamGateway {
|
||||
constructor(private readonly options: { readonly transport: SafeUpstreamTransport }) {}
|
||||
async postNetworkRegisterAuto(origin: string): Promise<UpstreamResponse> {
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(origin);
|
||||
} catch {
|
||||
throw this.notDispatched();
|
||||
}
|
||||
if (
|
||||
(parsed.protocol !== 'http:' && parsed.protocol !== 'https:') ||
|
||||
parsed.username ||
|
||||
parsed.password ||
|
||||
parsed.pathname !== '/' ||
|
||||
parsed.search ||
|
||||
parsed.hash
|
||||
)
|
||||
throw this.notDispatched();
|
||||
const url = `${parsed.origin}/api/network/register-auto`;
|
||||
try {
|
||||
return await this.options.transport.post(url, {}, '');
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof UpstreamError &&
|
||||
(error.code === 'UNSAFE_ORIGIN' || error.code === 'UNSAFE_RESOLUTION')
|
||||
)
|
||||
throw new OperationNotDispatchedError(error.code);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
async request(request: UpstreamRequest): Promise<UpstreamResponse> {
|
||||
const url = new URL(request.url);
|
||||
if (url.protocol !== 'https:') throw new UpstreamError('UPSTREAM_INSECURE_AUTH');
|
||||
@@ -35,4 +64,7 @@ export class SafeUpstreamGateway {
|
||||
}
|
||||
throw new UpstreamError('UPSTREAM_REQUEST_INVALID');
|
||||
}
|
||||
private notDispatched(): OperationNotDispatchedError {
|
||||
return new OperationNotDispatchedError('UPSTREAM_REQUEST_INVALID');
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user