Skip to content
Draft
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
6 changes: 4 additions & 2 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,9 +51,11 @@ The credential PostgreSQL adapter requires canonical persisted UUIDv4 identity,

The Plugin-owned Vault adapter uses one operator-configured canonical HTTPS HashiCorp Vault origin and KV v2 mount. One credential-binding UUID maps to one deterministic opaque `lifeos-plugin-vault://` reference and one Vault path. Creation uses KV v2 `cas: 0`; a lost/ambiguous completion or concurrent CAS loser is accepted as replay only after the durable Vault winner is read back and its canonical binding/installation/workspace/installer/name evidence and secret bytes exactly match the attempted write. A different secret for the same binding fails closed instead of overwriting provider state. Redirects are rejected, requests have a finite deadline, replay responses are size-bounded, caller and Vault-returned binding/installation/workspace/user UUIDs must already be canonical lowercase evidence, and external failures expose only the fixed Plugin secret-storage error. The adapter never normalizes an authority identity before selecting a Vault path or accepting replay evidence. This adapter owns no LifeOS database table and copies no Calendar secret-store implementation.

The authenticated Plugin application composition reads only `INTEGRATION_OPERATOR_CONTEXT_SECRET` and the service-owned `INTEGRATION_PLUGIN_VAULT_ORIGIN`, `INTEGRATION_PLUGIN_VAULT_TOKEN`, and `INTEGRATION_PLUGIN_VAULT_MOUNT` configuration names. Generic `PLUGIN_VAULT_*` aliases are not authority. It creates the Vault adapter only behind the existing Integration-owned installation, credential-metadata, and durable replay ports, so signed workspace/user authority and one-time replay consumption remain upstream of credential materialization. Composition failures collapse to one credential-free configuration error rather than reflecting verifier/Vault values.

The integration service separately owns `life-os.plugin-delivery-origin.v1` grants that bind one opaque grant ID to an exact installation, workspace, granting user, and normalized HTTPS origin. Grant creation requires active host-owned installation evidence. PostgreSQL grant admission rechecks that lifecycle at INSERT and serializes it against installation revocation. Every read that would expose an active origin grant re-resolves exact installation/workspace/user authority and requires the installation to remain active, so installation revocation fences future use of the origin grant. Revoked grant records remain readable as bounded lifecycle/audit evidence inside their original scope. Grant and installation chronology must also be internally consistent.

Neither credential binding nor origin-grant persistence performs outbound plugin delivery. The concrete Vault adapter is an active implementation slice, but hosted credential materialization is not production-complete until authenticated Plugin operator composition supplies its Vault origin/token/mount through operator-owned secret configuration, preserves fail-closed startup/runtime behavior when that dependency is unavailable, and proves a real Vault integration path. Outbound plugin delivery additionally requires separately reviewed DNS/IP rebinding resistance, connect-time address enforcement, redirect/proxy policy, bounded request/response sizes and phase/total deadlines, retry/dead-letter behavior, revocation fencing, and durable delivery outcome evidence. An origin grant or opaque secret reference alone never implies those capabilities.
Neither credential binding nor origin-grant persistence performs outbound plugin delivery. The Vault adapter and authenticated application composition are active implementation slices, but hosted credential materialization is not production-complete until one Integration-owned runtime constructs the required PostgreSQL installation/binding/replay ports, registers the composed operator before the listener starts, fails closed when its PostgreSQL/Vault/operator dependencies are unavailable, closes its owned pool exactly once, and proves the composed path against a real Vault. Outbound plugin delivery additionally requires separately reviewed DNS/IP rebinding resistance, connect-time address enforcement, redirect/proxy policy, bounded request/response sizes and phase/total deadlines, retry/dead-letter behavior, revocation fencing, and durable delivery outcome evidence. An origin grant or opaque secret reference alone never implies those capabilities.

## 2. AI proposal safety boundary

Expand Down Expand Up @@ -142,4 +144,4 @@ The pinned OpenCode configuration disables project-local overrides, explicitly r
7. `docs/research/` — standards and research rationale with APA 7 references.
8. `CHANGELOG.md` — user-visible unreleased and released changes.

A behavior or boundary change is incomplete until the relevant level is updated and executable tests prove the claim.
A behavior or boundary change is incomplete until the relevant level is updated and executable tests prove the claim.
4 changes: 3 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ All notable changes to LifeOS are documented in this file.

### Added

- An authenticated Plugin Vault operator composition that keeps signed workspace/user authority and durable replay consumption upstream of secret materialization, uses only Integration-owned installation/credential/replay ports, and reads Vault origin/token/mount from service-owned `INTEGRATION_PLUGIN_VAULT_*` configuration without making the composition factory a hosted PostgreSQL owner.
- A Plugin-owned HashiCorp Vault KV v2 secret-store adapter that binds one credential UUID to one opaque reference/path, uses create-only CAS for concurrent/retry safety, accepts replay only after exact durable authority-and-secret comparison, rejects redirects, bounds transport deadlines/replay response size, and never stores Vault credentials or provider plaintext in LifeOS persistence.
- Durable PostgreSQL plugin-installation authority with opaque UUIDv4 installation/workspace/installer identity, exact manifest digests, normalized explicit grants, bounded conflict replay, and atomic revocation evidence in the service-owned `plugin_integration` schema.
- A host-owned `life-os.plugin-delivery-origin.v1` authority boundary that accepts only exact normalized HTTPS origins after active installation evidence, scopes grant lifecycle to installation/workspace/granting-user identity, revalidates current active installation authority before exposing an active grant so installation revocation fences future origin use, and keeps manifest intent separate from network authority.
Expand All @@ -34,6 +35,7 @@ All notable changes to LifeOS are documented in this file.

### Fixed

- Plugin Vault operator configuration no longer accepts generic `PLUGIN_VAULT_*` aliases as credential authority; origin, token, and mount must come from the Integration service-owned configuration namespace and malformed/incomplete composition still fails through one credential-free error.
- Plugin credential binding now validates command/context envelopes, server-clock evidence, persistence result envelopes, canonical durable UUID/timestamp/lifecycle evidence, and exact absence sentinels before they become authority; malformed durable reads cannot cause fresh secret materialization, malformed create winners trigger secret compensation instead of leaving provider material silently orphaned, and credential/install chronology is fenced so future or pre-installation bindings and future-dated revocation replays cannot become current authority or trigger provider deletion.
- The public Gateway Today endpoint now fails explicitly with bounded `today_composition_unavailable` problem details instead of returning fabricated successful composition data while authenticated Planning/Habit integration is absent; issue #163 remains open for the real composition path.
- Data-rights request-ID and idempotency collisions now resolve through stable credential-free domain conflicts instead of exposing raw PostgreSQL uniqueness errors, including ambiguous dual-collision evidence.
Expand Down Expand Up @@ -62,4 +64,4 @@ All notable changes to LifeOS are documented in this file.
- Planning-search upstream responses are stopped at a fixed byte limit before they can be fully buffered by the web boundary.
- Notification persistence stores SHA-256 idempotency digests instead of raw delivery keys, validates every untrusted row, and keeps all SQL tenant-scoped and parameterized.
- The AI production boundary rejects direct client-selected ownership headers, verifies a short-lived HMAC-SHA-256 context bound to workspace, actor, HTTP method, and exact path, returns credential-free problem details, and exposes no proposal apply or execution route.
- The AI web boundary consumes and bounds identity-session response streams exactly once, avoiding unbounded buffering from cloning an untrusted streamed response.
- The AI web boundary consumes and bounds identity-session response streams exactly once, avoiding unbounded buffering from cloning an untrusted streamed response.
187 changes: 187 additions & 0 deletions apps/integration-service/src/plugin-vault-operator-composition.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
import { createHmac } from 'node:crypto';
import { describe, expect, it, vi } from 'vitest';
import type {
PluginCredentialBindingRecord,
PluginCredentialBindingStore,
} from './plugin-credential';
import type {
PluginInstallationContext,
PluginInstallationRecord,
} from './plugin-installation';
import type { PluginInstallationOperatorPort } from './plugin-operator-application';
import type { PluginOperatorReplayGuardPort } from './plugin-operator-replay';
import type {
PluginVaultHttpClient,
PluginVaultHttpResponse,
} from './plugin-vault-secret-store';
import {
createPluginVaultOperatorApplication,
PluginVaultOperatorCompositionError,
} from './plugin-vault-operator-composition';

const WORKSPACE_ID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa';
const USER_ID = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb';
const INSTALLATION_ID = 'cccccccc-cccc-4ccc-8ccc-cccccccccccc';
const BINDING_ID = 'dddddddd-dddd-4ddd-8ddd-dddddddddddd';
const EVIDENCE_ID = 'eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee';
const CONTEXT_SECRET = 'operator-context-fixture-value-32-bytes-minimum';
const VAULT_TOKEN = 'vault-fixture-token-value';
const ISSUED_AT = 1_700_000_000;

const INSTALLATION: PluginInstallationRecord = Object.freeze({
installationId: INSTALLATION_ID,
workspaceId: WORKSPACE_ID,
installedByUserId: USER_ID,
pluginId: 'example.plugin',
pluginContractVersion: '1.0.0',
manifestSha256: 'a'.repeat(64),
grantedCapabilities: Object.freeze(['event.task.completed']),
status: 'active',
installedAt: '2023-11-14T22:13:19.000Z',
revokedAt: null,
});

function response(status: number): PluginVaultHttpResponse {
return {
status,
headers: { get: () => null },
body: new ReadableStream<Uint8Array>({
start(controller) {
controller.close();
},
}),
};
}

class InstallationPort implements PluginInstallationOperatorPort {
async install(): Promise<PluginInstallationRecord> {
return INSTALLATION;
}

async getInstallation(
context: PluginInstallationContext,
installationId: string,
): Promise<PluginInstallationRecord | undefined> {
return context.workspaceId === WORKSPACE_ID &&
context.actorUserId === USER_ID &&
installationId === INSTALLATION_ID
? INSTALLATION
: undefined;
}

async revoke(): Promise<PluginInstallationRecord> {
return Object.freeze({
...INSTALLATION,
status: 'revoked',
revokedAt: '2023-11-14T22:13:21.000Z',
});
}
}

class BindingStore implements PluginCredentialBindingStore {
private record: PluginCredentialBindingRecord | undefined;

async findById(): Promise<PluginCredentialBindingRecord | undefined> {
return this.record;
}

async createIfAbsent(
record: PluginCredentialBindingRecord,
): Promise<PluginCredentialBindingRecord> {
this.record ??= record;
return this.record;
}

async revokeActive(): Promise<PluginCredentialBindingRecord | undefined> {
return this.record;
}
}

function environment(): Readonly<Record<string, string>> {
return Object.freeze({
INTEGRATION_OPERATOR_CONTEXT_SECRET: CONTEXT_SECRET,
INTEGRATION_PLUGIN_VAULT_ORIGIN: 'https://vault.example.test',
INTEGRATION_PLUGIN_VAULT_TOKEN: VAULT_TOKEN,
INTEGRATION_PLUGIN_VAULT_MOUNT: 'secret',
});
}

function signedHeaders(): Readonly<{
workspaceId: string;
userId: string;
evidenceId: string;
issuedAt: string;
signature: string;
}> {
const issuedAt = String(ISSUED_AT);
const signature = createHmac('sha256', CONTEXT_SECRET)
.update(
`life-os.integration-operator-context.v1\n${WORKSPACE_ID}\n${USER_ID}\n${EVIDENCE_ID}\n${issuedAt}\nPOST\n/v1/plugins/credential-bindings`,
'utf8',
)
.digest('base64url');
return Object.freeze({
workspaceId: WORKSPACE_ID,
userId: USER_ID,
evidenceId: EVIDENCE_ID,
issuedAt,
signature,
});
}

describe('Plugin Vault operator composition', () => {
it('fails closed at composition when operator-owned Vault configuration is incomplete', () => {
const installations = new InstallationPort();
const bindingStore = new BindingStore();
const replayGuard: PluginOperatorReplayGuardPort = {
consume: vi.fn(async () => true),
};

expect(() =>
createPluginVaultOperatorApplication(
{ installations, bindingStore, replayGuard },
{
INTEGRATION_OPERATOR_CONTEXT_SECRET: CONTEXT_SECRET,
INTEGRATION_PLUGIN_VAULT_ORIGIN: 'https://vault.example.test',
INTEGRATION_PLUGIN_VAULT_TOKEN: VAULT_TOKEN,
},
),
).toThrow(PluginVaultOperatorCompositionError);
});

it('binds credential authority through the composed Vault store after signed operator verification', async () => {
const installations = new InstallationPort();
const bindingStore = new BindingStore();
const replayGuard: PluginOperatorReplayGuardPort = {
consume: vi.fn(async () => true),
};
const http = vi.fn<PluginVaultHttpClient>().mockResolvedValue(response(200));
const operator = createPluginVaultOperatorApplication(
{ installations, bindingStore, replayGuard },
environment(),
http,
() => ISSUED_AT,
() => new Date('2023-11-14T22:13:20.000Z'),
);

const result = await operator.bindCredential(signedHeaders(), {
credentialBindingId: BINDING_ID,
installationId: INSTALLATION_ID,
credentialName: 'webhook.signing',
secretValue: 'buyer secret fixture',
});

expect(result).toEqual({
credentialBindingId: BINDING_ID,
installationId: INSTALLATION_ID,
workspaceId: WORKSPACE_ID,
installedByUserId: USER_ID,
credentialName: 'webhook.signing',
status: 'active',
boundAt: '2023-11-14T22:13:20.000Z',
revokedAt: null,
});
expect(http).toHaveBeenCalledTimes(1);
expect(replayGuard.consume).toHaveBeenCalledTimes(1);
});
});
Loading