Compare commits
2 Commits
586129faf4
...
d1925149c6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d1925149c6 | ||
|
|
508f57118d |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -9,3 +9,6 @@ coverage
|
|||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
.pnpm-store
|
.pnpm-store
|
||||||
docs/reports/
|
docs/reports/
|
||||||
|
|
||||||
|
# Step-CA dev password — real file is gitignored; commit only the .example
|
||||||
|
infra/step-ca/dev-password
|
||||||
|
|||||||
187
apps/gateway/src/federation/scope-schema.spec.ts
Normal file
187
apps/gateway/src/federation/scope-schema.spec.ts
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
/**
|
||||||
|
* Unit tests for FederationScopeSchema and parseFederationScope.
|
||||||
|
*
|
||||||
|
* Coverage:
|
||||||
|
* - Valid: minimal scope
|
||||||
|
* - Valid: full PRD §8.1 example
|
||||||
|
* - Valid: resources + excluded_resources (no overlap)
|
||||||
|
* - Invalid: empty resources
|
||||||
|
* - Invalid: unknown resource value
|
||||||
|
* - Invalid: resources / excluded_resources intersection
|
||||||
|
* - Invalid: filter key not in resources
|
||||||
|
* - Invalid: max_rows_per_query = 0
|
||||||
|
* - Invalid: max_rows_per_query = 10001
|
||||||
|
* - Invalid: not an object / null
|
||||||
|
* - Defaults: include_personal defaults to true; excluded_resources defaults to []
|
||||||
|
* - Sentinel: console.warn fires for sensitive resources
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||||
|
import {
|
||||||
|
parseFederationScope,
|
||||||
|
FederationScopeError,
|
||||||
|
FederationScopeSchema,
|
||||||
|
} from './scope-schema.js';
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('parseFederationScope — valid inputs', () => {
|
||||||
|
it('accepts a minimal scope (resources + max_rows_per_query only)', () => {
|
||||||
|
const scope = parseFederationScope({
|
||||||
|
resources: ['tasks'],
|
||||||
|
max_rows_per_query: 100,
|
||||||
|
});
|
||||||
|
expect(scope.resources).toEqual(['tasks']);
|
||||||
|
expect(scope.max_rows_per_query).toBe(100);
|
||||||
|
expect(scope.excluded_resources).toEqual([]);
|
||||||
|
expect(scope.filters).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts the full PRD §8.1 example', () => {
|
||||||
|
const scope = parseFederationScope({
|
||||||
|
resources: ['tasks', 'notes', 'memory'],
|
||||||
|
filters: {
|
||||||
|
tasks: { include_teams: ['team_uuid_1', 'team_uuid_2'], include_personal: true },
|
||||||
|
notes: { include_personal: true, include_teams: [] },
|
||||||
|
memory: { include_personal: true },
|
||||||
|
},
|
||||||
|
excluded_resources: ['credentials', 'api_keys'],
|
||||||
|
max_rows_per_query: 500,
|
||||||
|
});
|
||||||
|
expect(scope.resources).toEqual(['tasks', 'notes', 'memory']);
|
||||||
|
expect(scope.excluded_resources).toEqual(['credentials', 'api_keys']);
|
||||||
|
expect(scope.filters?.tasks?.include_teams).toEqual(['team_uuid_1', 'team_uuid_2']);
|
||||||
|
expect(scope.max_rows_per_query).toBe(500);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts a scope with excluded_resources and no filter overlap', () => {
|
||||||
|
const scope = parseFederationScope({
|
||||||
|
resources: ['tasks', 'notes'],
|
||||||
|
excluded_resources: ['memory'],
|
||||||
|
max_rows_per_query: 250,
|
||||||
|
});
|
||||||
|
expect(scope.resources).toEqual(['tasks', 'notes']);
|
||||||
|
expect(scope.excluded_resources).toEqual(['memory']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('parseFederationScope — defaults', () => {
|
||||||
|
it('defaults excluded_resources to []', () => {
|
||||||
|
const scope = parseFederationScope({ resources: ['tasks'], max_rows_per_query: 1 });
|
||||||
|
expect(scope.excluded_resources).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('defaults include_personal to true when filter is provided without it', () => {
|
||||||
|
const scope = parseFederationScope({
|
||||||
|
resources: ['tasks'],
|
||||||
|
filters: { tasks: { include_teams: ['t1'] } },
|
||||||
|
max_rows_per_query: 10,
|
||||||
|
});
|
||||||
|
expect(scope.filters?.tasks?.include_personal).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('parseFederationScope — invalid inputs', () => {
|
||||||
|
it('throws FederationScopeError for empty resources array', () => {
|
||||||
|
expect(() => parseFederationScope({ resources: [], max_rows_per_query: 100 })).toThrow(
|
||||||
|
FederationScopeError,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws for unknown resource value in resources', () => {
|
||||||
|
expect(() =>
|
||||||
|
parseFederationScope({ resources: ['unknown_resource'], max_rows_per_query: 100 }),
|
||||||
|
).toThrow(FederationScopeError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws when resources and excluded_resources intersect', () => {
|
||||||
|
expect(() =>
|
||||||
|
parseFederationScope({
|
||||||
|
resources: ['tasks', 'memory'],
|
||||||
|
excluded_resources: ['memory'],
|
||||||
|
max_rows_per_query: 100,
|
||||||
|
}),
|
||||||
|
).toThrow(FederationScopeError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws when filters references a resource not in resources', () => {
|
||||||
|
expect(() =>
|
||||||
|
parseFederationScope({
|
||||||
|
resources: ['tasks'],
|
||||||
|
filters: { notes: { include_personal: true } },
|
||||||
|
max_rows_per_query: 100,
|
||||||
|
}),
|
||||||
|
).toThrow(FederationScopeError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws for max_rows_per_query = 0', () => {
|
||||||
|
expect(() => parseFederationScope({ resources: ['tasks'], max_rows_per_query: 0 })).toThrow(
|
||||||
|
FederationScopeError,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws for max_rows_per_query = 10001', () => {
|
||||||
|
expect(() => parseFederationScope({ resources: ['tasks'], max_rows_per_query: 10001 })).toThrow(
|
||||||
|
FederationScopeError,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws for null input', () => {
|
||||||
|
expect(() => parseFederationScope(null)).toThrow(FederationScopeError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws for non-object input (string)', () => {
|
||||||
|
expect(() => parseFederationScope('not-an-object')).toThrow(FederationScopeError);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('parseFederationScope — sentinel warning', () => {
|
||||||
|
it('emits console.warn when resources includes "credentials"', () => {
|
||||||
|
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||||
|
parseFederationScope({
|
||||||
|
resources: ['tasks', 'credentials'],
|
||||||
|
max_rows_per_query: 100,
|
||||||
|
});
|
||||||
|
expect(warnSpy).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining(
|
||||||
|
'[FederationScope] WARNING: scope grants sensitive resource "credentials"',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('emits console.warn when resources includes "api_keys"', () => {
|
||||||
|
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||||
|
parseFederationScope({
|
||||||
|
resources: ['tasks', 'api_keys'],
|
||||||
|
max_rows_per_query: 100,
|
||||||
|
});
|
||||||
|
expect(warnSpy).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining(
|
||||||
|
'[FederationScope] WARNING: scope grants sensitive resource "api_keys"',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does NOT emit console.warn for non-sensitive resources', () => {
|
||||||
|
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||||
|
parseFederationScope({ resources: ['tasks', 'notes', 'memory'], max_rows_per_query: 100 });
|
||||||
|
expect(warnSpy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('FederationScopeSchema — boundary values', () => {
|
||||||
|
it('accepts max_rows_per_query = 1 (lower bound)', () => {
|
||||||
|
const result = FederationScopeSchema.safeParse({ resources: ['tasks'], max_rows_per_query: 1 });
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts max_rows_per_query = 10000 (upper bound)', () => {
|
||||||
|
const result = FederationScopeSchema.safeParse({
|
||||||
|
resources: ['tasks'],
|
||||||
|
max_rows_per_query: 10000,
|
||||||
|
});
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
147
apps/gateway/src/federation/scope-schema.ts
Normal file
147
apps/gateway/src/federation/scope-schema.ts
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
/**
|
||||||
|
* Federation grant scope schema and validator.
|
||||||
|
*
|
||||||
|
* Source of truth: docs/federation/PRD.md §8.1
|
||||||
|
*
|
||||||
|
* This module is intentionally pure — no DB, no NestJS, no CA wiring.
|
||||||
|
* It is reusable from grant CRUD (M2-06) and scope enforcement (M3+).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Allowlist of federation resources (canonical — M3+ will extend this list)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
export const FEDERATION_RESOURCE_VALUES = [
|
||||||
|
'tasks',
|
||||||
|
'notes',
|
||||||
|
'memory',
|
||||||
|
'credentials',
|
||||||
|
'api_keys',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type FederationResource = (typeof FEDERATION_RESOURCE_VALUES)[number];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sensitive resources require explicit admin approval (PRD §8.4).
|
||||||
|
* The parser warns when these appear in `resources`; M2-06 grant CRUD
|
||||||
|
* will add a hard gate on top of this warning.
|
||||||
|
*/
|
||||||
|
const SENSITIVE_RESOURCES: ReadonlySet<FederationResource> = new Set(['credentials', 'api_keys']);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Sub-schemas
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const ResourceArraySchema = z
|
||||||
|
.array(z.enum(FEDERATION_RESOURCE_VALUES))
|
||||||
|
.nonempty({ message: 'resources must contain at least one value' })
|
||||||
|
.refine((arr) => new Set(arr).size === arr.length, {
|
||||||
|
message: 'resources must not contain duplicate values',
|
||||||
|
});
|
||||||
|
|
||||||
|
const ResourceFilterSchema = z.object({
|
||||||
|
include_teams: z.array(z.string()).optional(),
|
||||||
|
include_personal: z.boolean().default(true),
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Top-level schema
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export const FederationScopeSchema = z
|
||||||
|
.object({
|
||||||
|
resources: ResourceArraySchema,
|
||||||
|
|
||||||
|
excluded_resources: z
|
||||||
|
.array(z.enum(FEDERATION_RESOURCE_VALUES))
|
||||||
|
.default([])
|
||||||
|
.refine((arr) => new Set(arr).size === arr.length, {
|
||||||
|
message: 'excluded_resources must not contain duplicate values',
|
||||||
|
}),
|
||||||
|
|
||||||
|
filters: z.record(z.string(), ResourceFilterSchema).optional(),
|
||||||
|
|
||||||
|
max_rows_per_query: z
|
||||||
|
.number()
|
||||||
|
.int({ message: 'max_rows_per_query must be an integer' })
|
||||||
|
.min(1, { message: 'max_rows_per_query must be at least 1' })
|
||||||
|
.max(10000, { message: 'max_rows_per_query must be at most 10000' }),
|
||||||
|
})
|
||||||
|
.superRefine((data, ctx) => {
|
||||||
|
const resourceSet = new Set(data.resources);
|
||||||
|
|
||||||
|
// Intersection guard: a resource cannot be both granted and excluded
|
||||||
|
for (const r of data.excluded_resources) {
|
||||||
|
if (resourceSet.has(r)) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: `Resource "${r}" appears in both resources and excluded_resources`,
|
||||||
|
path: ['excluded_resources'],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter keys must be a subset of resources
|
||||||
|
if (data.filters) {
|
||||||
|
for (const key of Object.keys(data.filters)) {
|
||||||
|
if (!resourceSet.has(key as FederationResource)) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: `filters key "${key}" references a resource not present in resources`,
|
||||||
|
path: ['filters', key],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export type FederationScope = z.infer<typeof FederationScopeSchema>;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Error class
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export class FederationScopeError extends Error {
|
||||||
|
constructor(message: string) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'FederationScopeError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Typed parser
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse and validate an unknown value as a FederationScope.
|
||||||
|
*
|
||||||
|
* Throws `FederationScopeError` with aggregated Zod issues on failure.
|
||||||
|
*
|
||||||
|
* Emits `console.warn` when sensitive resources (`credentials`, `api_keys`)
|
||||||
|
* are present in `resources` — per PRD §8.4, these require explicit admin
|
||||||
|
* approval. M2-06 grant CRUD will add a hard gate on top of this warning.
|
||||||
|
*/
|
||||||
|
export function parseFederationScope(input: unknown): FederationScope {
|
||||||
|
const result = FederationScopeSchema.safeParse(input);
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
const issues = result.error.issues
|
||||||
|
.map((e) => ` - [${e.path.join('.') || 'root'}] ${e.message}`)
|
||||||
|
.join('\n');
|
||||||
|
throw new FederationScopeError(`Invalid federation scope:\n${issues}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const scope = result.data;
|
||||||
|
|
||||||
|
// Sentinel warning for sensitive resources (PRD §8.4)
|
||||||
|
for (const resource of scope.resources) {
|
||||||
|
if (SENSITIVE_RESOURCES.has(resource)) {
|
||||||
|
console.warn(
|
||||||
|
`[FederationScope] WARNING: scope grants sensitive resource "${resource}". Per PRD §8.4 this requires explicit admin approval and is logged.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return scope;
|
||||||
|
}
|
||||||
@@ -55,6 +55,63 @@ services:
|
|||||||
timeout: 3s
|
timeout: 3s
|
||||||
retries: 5
|
retries: 5
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Step-CA — Mosaic Federation internal certificate authority
|
||||||
|
#
|
||||||
|
# Image: pinned to 0.27.4 (latest stable as of late 2025).
|
||||||
|
# `latest` is forbidden per Mosaic image policy (immutable tag required for
|
||||||
|
# reproducible deployments and digest-first promotion in CI).
|
||||||
|
#
|
||||||
|
# Profile: `federated` — this service must not start in non-federated dev.
|
||||||
|
#
|
||||||
|
# Password:
|
||||||
|
# Dev: bind-mount ./infra/step-ca/dev-password (gitignored; copy from
|
||||||
|
# ./infra/step-ca/dev-password.example and customise locally).
|
||||||
|
# Prod: replace the bind-mount with a Docker secret:
|
||||||
|
# secrets:
|
||||||
|
# ca_password:
|
||||||
|
# external: true
|
||||||
|
# and reference it as `/run/secrets/ca_password` (same path the
|
||||||
|
# init script already uses).
|
||||||
|
#
|
||||||
|
# Provisioner: "mosaic-fed" (consumed by apps/gateway/src/federation/ca.service.ts)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
step-ca:
|
||||||
|
image: smallstep/step-ca:0.27.4
|
||||||
|
profiles: [federated]
|
||||||
|
ports:
|
||||||
|
- '${STEP_CA_HOST_PORT:-9000}:9000'
|
||||||
|
volumes:
|
||||||
|
- step_ca_data:/home/step
|
||||||
|
# init script — executed as the container entrypoint
|
||||||
|
- ./infra/step-ca/init.sh:/usr/local/bin/mosaic-step-ca-init.sh:ro
|
||||||
|
# X.509 template skeleton (wired in M2-04)
|
||||||
|
- ./infra/step-ca/templates:/etc/step-ca-templates:ro
|
||||||
|
# Dev password file — GITIGNORED; copy from dev-password.example
|
||||||
|
# In production, replace this with a Docker secret (see comment above).
|
||||||
|
- ./infra/step-ca/dev-password:/run/secrets/ca_password:ro
|
||||||
|
entrypoint: ['/bin/sh', '/usr/local/bin/mosaic-step-ca-init.sh']
|
||||||
|
healthcheck:
|
||||||
|
# The healthcheck requires the root cert to exist, which is only true
|
||||||
|
# after init.sh has completed on first boot. start_period gives init
|
||||||
|
# time to finish before Docker starts counting retries.
|
||||||
|
test:
|
||||||
|
[
|
||||||
|
'CMD',
|
||||||
|
'step',
|
||||||
|
'ca',
|
||||||
|
'health',
|
||||||
|
'--ca-url',
|
||||||
|
'https://localhost:9000',
|
||||||
|
'--root',
|
||||||
|
'/home/step/certs/root_ca.crt',
|
||||||
|
]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
start_period: 30s
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
pg_federated_data:
|
pg_federated_data:
|
||||||
valkey_federated_data:
|
valkey_federated_data:
|
||||||
|
step_ca_data:
|
||||||
|
|||||||
1
infra/step-ca/dev-password.example
Normal file
1
infra/step-ca/dev-password.example
Normal file
@@ -0,0 +1 @@
|
|||||||
|
dev-only-step-ca-password-do-not-use-in-production
|
||||||
60
infra/step-ca/init.sh
Executable file
60
infra/step-ca/init.sh
Executable file
@@ -0,0 +1,60 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# infra/step-ca/init.sh
|
||||||
|
#
|
||||||
|
# Idempotent first-boot initialiser for the Mosaic Federation CA.
|
||||||
|
#
|
||||||
|
# On the first run (no /home/step/config/ca.json present) this script:
|
||||||
|
# 1. Initialises Step-CA with a JWK provisioner named "mosaic-fed".
|
||||||
|
# 2. Writes the CA configuration to the persistent volume at /home/step.
|
||||||
|
#
|
||||||
|
# On subsequent runs (config already exists) this script skips init and
|
||||||
|
# starts the CA directly.
|
||||||
|
#
|
||||||
|
# The provisioner name "mosaic-fed" is consumed by:
|
||||||
|
# apps/gateway/src/federation/ca.service.ts (added in M2-04)
|
||||||
|
#
|
||||||
|
# Password source:
|
||||||
|
# Dev: mounted from ./infra/step-ca/dev-password via bind mount.
|
||||||
|
# Prod: mounted from a Docker secret at /run/secrets/ca_password.
|
||||||
|
#
|
||||||
|
# OID template:
|
||||||
|
# infra/step-ca/templates/federation.tpl is copied into the CA config
|
||||||
|
# directory so the JWK provisioner can reference it. The template
|
||||||
|
# skeleton is wired in M2-04 when the CA service lands the SAN-bearing
|
||||||
|
# CSR work.
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
CA_CONFIG="/home/step/config/ca.json"
|
||||||
|
PASSWORD_FILE="/run/secrets/ca_password"
|
||||||
|
|
||||||
|
if [ ! -f "${CA_CONFIG}" ]; then
|
||||||
|
echo "[step-ca init] First boot detected — initialising Mosaic Federation CA..."
|
||||||
|
|
||||||
|
step ca init \
|
||||||
|
--name "Mosaic Federation CA" \
|
||||||
|
--dns "localhost" \
|
||||||
|
--dns "step-ca" \
|
||||||
|
--address ":9000" \
|
||||||
|
--provisioner "mosaic-fed" \
|
||||||
|
--password-file "${PASSWORD_FILE}" \
|
||||||
|
--provisioner-password-file "${PASSWORD_FILE}" \
|
||||||
|
--no-db
|
||||||
|
|
||||||
|
echo "[step-ca init] CA initialised."
|
||||||
|
|
||||||
|
# Copy the X.509 template into the Step-CA config directory so the
|
||||||
|
# provisioner can reference it in M2-04.
|
||||||
|
if [ -f "/etc/step-ca-templates/federation.tpl" ]; then
|
||||||
|
mkdir -p /home/step/templates
|
||||||
|
cp /etc/step-ca-templates/federation.tpl /home/step/templates/federation.tpl
|
||||||
|
echo "[step-ca init] Federation X.509 template copied to /home/step/templates/."
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "[step-ca init] Startup complete."
|
||||||
|
else
|
||||||
|
echo "[step-ca init] Config already exists — skipping init."
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "[step-ca init] Starting Step-CA on :9000..."
|
||||||
|
exec step-ca /home/step/config/ca.json --password-file "${PASSWORD_FILE}"
|
||||||
48
infra/step-ca/templates/federation.tpl
Normal file
48
infra/step-ca/templates/federation.tpl
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
{
|
||||||
|
"subject": {{ toJson .Subject }},
|
||||||
|
"sans": {{ toJson .SANs }},
|
||||||
|
|
||||||
|
{{- /*
|
||||||
|
Mosaic Federation X.509 Certificate Template
|
||||||
|
============================================
|
||||||
|
This template is used by the "mosaic-fed" JWK provisioner to sign
|
||||||
|
federation client certificates.
|
||||||
|
|
||||||
|
Custom OID extensions (per PRD §6):
|
||||||
|
1.3.6.1.4.1.99999.1 — mosaic.federation.grantId (UUID string)
|
||||||
|
1.3.6.1.4.1.99999.2 — mosaic.federation.subjectUserId (UUID string)
|
||||||
|
|
||||||
|
TODO (M2-04): Wire actual OID extensions below once the CA service
|
||||||
|
(apps/gateway/src/federation/ca.service.ts) lands the SAN-bearing CSR
|
||||||
|
work and the template can be exercised end-to-end.
|
||||||
|
|
||||||
|
Step-CA template reference:
|
||||||
|
https://smallstep.com/docs/step-ca/templates
|
||||||
|
|
||||||
|
Expected final shape of the extensions block (placeholder — not yet
|
||||||
|
activated):
|
||||||
|
|
||||||
|
"extensions": [
|
||||||
|
{
|
||||||
|
"id": "1.3.6.1.4.1.99999.1",
|
||||||
|
"critical": false,
|
||||||
|
"value": {{ toJson (first .Token.mosaic_grant_id) }}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "1.3.6.1.4.1.99999.2",
|
||||||
|
"critical": false,
|
||||||
|
"value": {{ toJson (first .Token.mosaic_subject_user_id) }}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
|
||||||
|
The provisioner must pass these values in the ACME/JWK token payload
|
||||||
|
(token claims `mosaic_grant_id` and `mosaic_subject_user_id`) when
|
||||||
|
submitting the CSR. M2-04 owns that work.
|
||||||
|
*/ -}}
|
||||||
|
|
||||||
|
"keyUsage": ["digitalSignature"],
|
||||||
|
"extKeyUsage": ["clientAuth"],
|
||||||
|
"basicConstraints": {
|
||||||
|
"isCA": false
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user