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
2 changes: 2 additions & 0 deletions docs/reference/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ Current groups:
registration, extension packaging, readiness, and lifecycle boundaries.
- [Project skill delivery](../../loopx/capabilities/project_skill_delivery/README.md): release-owned,
project-local skill discovery and managed-copy lifecycle.
- [PostgreSQL authority service admission v0](postgresql-authority-service-v0.md): opt-in
authentication, tenant authorization, and restore-incarnation rotation.

High-traffic read paths:

Expand Down
67 changes: 67 additions & 0 deletions docs/reference/postgresql-authority-service-v0.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
# PostgreSQL authority service admission v0

This document defines the service-owned admission boundary for the switchable
PostgreSQL authority provider. It is an opt-in Stage 2B seam: it does not make
PostgreSQL the default, change file/SQLite selection, add a runtime caller, or
claim promotion readiness.

## Boundary

`PostgreSqlAuthorityService` receives an opaque transport credential, a tenant,
a goal, and the expected database-incarnation identity. It calls injected
authentication and tenant-authorization functions before opening the provider.
The credential is never persisted, passed to PostgreSQL, or exposed through the
provider-neutral `AuthorityStore` contract. A denied or unverifiable principal
fails closed before a database connection is opened.

The service returns one of these typed outcomes:

| Outcome | Meaning |
| --- | --- |
| `opened` | The principal is authenticated, authorized for the tenant, and the database identity matches the requested binding. |
| `principal_unauthenticated` | Authentication rejected the opaque credential or returned an invalid principal. |
| `principal_verification_unavailable` | Authentication could not be completed. |
| `tenant_unauthorized` | The authenticated principal is not allowed to use the tenant. |
| `tenant_authorization_unavailable` | Tenant policy could not be evaluated. |
| `store_identity_unavailable` | Provider metadata could not be read. |
| `store_identity_mismatch` | The requested incarnation is not the database's current incarnation. |

The service is intentionally in-process. A deployment supplies its own
transport, credential verifier, tenant policy, connection pool, and secret
handling. This module does not grant network access, actor ownership, lease
ownership, cross-host synchronization, or promotion authority.

## Restore-incarnation rotation

`rotatePostgreSqlAuthorityStoreIdentity` is an administrative operation owned by
the authenticated service deployment. It locks the singleton metadata row,
verifies the expected identity, and atomically writes a newly minted
`postgresql:<32 lowercase hex>` identity. It does not rewrite heads, commits,
events, receipts, goals, or operation IDs.

Provider revision tokens contain the database identity. Consequently, tokens
minted before a restore rotation become stale and conflict, while the durable
head and receipt history remain readable through a store opened with the new
identity. A wrong expected identity is rejected without a write. If the server
loses the response after `COMMIT`, the result is `ambiguous`; the service must
read metadata before retrying, rather than guessing whether rotation applied.

## Validation

The public synthetic fixture is
[`postgresql_authority_service_v0.json`](../../tests/fixtures/control_plane/postgresql_authority_service_v0.json).
It covers authenticated admission, authentication denial, tenant denial,
identity drift, and restore rotation without credentials or private database
details.

Run the deterministic seam tests with:

```sh
npm run typecheck:control-plane
npm run test:postgresql-authority-service
```

Run the real PostgreSQL path against an isolated disposable database by setting
`LOOPX_TEST_POSTGRES_SERVICE_URL` and invoking the same script. The database
must be disposable and separate from any active goal or other PostgreSQL test
schema; the integration test mutates only its own tenant/goal and metadata.
61 changes: 61 additions & 0 deletions docs/reference/postgresql-authority-service-v0.zh-CN.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
# PostgreSQL authority service 准入 v0

本文定义可切换 PostgreSQL authority provider 的服务侧准入边界。它是
Stage 2B 的 opt-in seam:不会把 PostgreSQL 变成默认 provider,不会改变
file/SQLite 选择逻辑,不会增加 runtime caller,也不表示已经满足 promotion
条件。

## 边界

`PostgreSqlAuthorityService` 接收不透明的传输凭证、tenant、goal 和期望的
数据库 incarnation identity,然后先调用注入的认证函数和 tenant 授权函数,
再打开 provider。凭证不会持久化、不会传给 PostgreSQL,也不会进入
provider-neutral 的 `AuthorityStore` 合同。principal 被拒绝或无法验证时,
服务会在建立数据库连接前 fail closed。

服务返回以下类型化结果之一:

| 结果 | 含义 |
| --- | --- |
| `opened` | principal 已认证、被授权使用该 tenant,且数据库 identity 与请求绑定一致。 |
| `principal_unauthenticated` | 凭证被拒绝,或认证函数返回了非法 principal。 |
| `principal_verification_unavailable` | 认证无法完成。 |
| `tenant_unauthorized` | 已认证 principal 无权使用该 tenant。 |
| `tenant_authorization_unavailable` | tenant policy 无法评估。 |
| `store_identity_unavailable` | 无法读取 provider metadata。 |
| `store_identity_mismatch` | 请求的 incarnation 不是数据库当前 incarnation。 |

该模块刻意保持为进程内边界。部署方负责传输、凭证校验、tenant policy、连接池
和 secret 处理;本模块不授予网络访问、actor ownership、lease ownership、跨主机
同步或 promotion 权限。

## restore-incarnation 轮换

`rotatePostgreSqlAuthorityStoreIdentity` 是认证服务部署拥有的管理操作。它锁定
singleton metadata 行,校验旧 identity,并原子写入新生成的
`postgresql:<32 位小写十六进制>` identity。它不会重写 head、commit、event、
receipt、goal 或 operation ID。

provider revision token 包含数据库 identity。因此 restore 轮换前签发的 token
会变成 stale 并产生 conflict;使用新 identity 打开的 store 仍能读取持久化的
head 和 receipt 历史。期望 identity 错误时不会写入。如果 PostgreSQL 在
`COMMIT` 后丢失响应,结果为 `ambiguous`;服务必须先重新读取 metadata 再重试,
不能猜测轮换是否已经生效。

## 验证

公开 synthetic fixture 是
[`postgresql_authority_service_v0.json`](../../tests/fixtures/control_plane/postgresql_authority_service_v0.json),
覆盖认证准入、认证拒绝、tenant 拒绝、identity 漂移和 restore 轮换,不包含凭证
或私有数据库信息。

确定性 seam 测试:

```sh
npm run typecheck:control-plane
npm run test:postgresql-authority-service
```

真实 PostgreSQL 路径需要将 `LOOPX_TEST_POSTGRES_SERVICE_URL` 指向隔离的临时
数据库,再执行同一命令。该数据库必须与活动 goal 及其他 PostgreSQL 测试 schema
分离;集成测试只修改自身的 tenant/goal 和 metadata。
212 changes: 212 additions & 0 deletions loopx/control_plane/coordination/postgresql_authority_service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,212 @@
import type { AuthorityStore } from "./authority_store.ts";
import {
POSTGRESQL_STORE_IDENTITY_PATTERN,
PostgreSqlAuthorityStore,
type PostgreSqlAuthorityDatabase,
type PostgreSqlAuthorityStoreOptions,
} from "./postgresql_authority_store.ts";
import { requireAuthorityStoreId } from "./authority_store_codec.ts";

/** A principal already verified by the service's authentication layer. */
export interface PostgreSqlAuthenticatedPrincipal {
principal_id: string;
}

export type PostgreSqlPrincipalAuthenticationResult =
| { status: "authenticated"; principal: PostgreSqlAuthenticatedPrincipal }
| { status: "rejected"; reason_code: string; reason: string };

export type PostgreSqlTenantAuthorizationResult =
| { status: "allowed" }
| { status: "denied"; reason_code: string; reason: string };

/**
* The service owns authentication and authorization. The provider only sees
* an already verified principal id and the authorized tenant binding.
*/
export interface PostgreSqlAuthorityServiceDependencies {
database: PostgreSqlAuthorityDatabase;
authenticatePrincipal(
credential: unknown,
): Promise<PostgreSqlPrincipalAuthenticationResult> | PostgreSqlPrincipalAuthenticationResult;
authorizeTenant(
principalId: string,
tenantId: string,
): Promise<PostgreSqlTenantAuthorizationResult> | PostgreSqlTenantAuthorizationResult;
max_commit_bytes?: PostgreSqlAuthorityStoreOptions["max_commit_bytes"];
}

export interface PostgreSqlAuthorityServiceOpenRequest {
/** Opaque transport credential; never persisted or passed to PostgreSQL. */
credential: unknown;
tenant_id: string;
goal_id: string;
store_identity: string;
}

export type PostgreSqlAuthorityServiceOpenResult =
| {
status: "opened";
provider: "postgresql";
principal_id: string;
tenant_id: string;
goal_id: string;
store_identity: string;
store: AuthorityStore;
}
| {
status: "rejected";
reason_code:
| "invalid_service_request"
| "principal_unauthenticated"
| "principal_verification_unavailable"
| "tenant_unauthorized"
| "tenant_authorization_unavailable"
| "store_identity_unavailable"
| "store_identity_mismatch";
reason: string;
principal_id?: string;
tenant_id?: string;
goal_id?: string;
};

type PostgreSqlAuthorityServiceRejected = Extract<
PostgreSqlAuthorityServiceOpenResult,
{status: "rejected"}
>;

function rejected(
reasonCode: PostgreSqlAuthorityServiceRejected["reason_code"],
reason: string,
facts: Partial<PostgreSqlAuthorityServiceRejected> = {},
): PostgreSqlAuthorityServiceRejected {
return {status: "rejected", reason_code: reasonCode, reason, ...facts};
}

function validIdentity(value: string): boolean {
return POSTGRESQL_STORE_IDENTITY_PATTERN.test(value);
}

/**
* Service-owned PostgreSQL admission. This is intentionally an in-process
* boundary: transport authentication, pool lifecycle, and credentials stay
* outside the provider-neutral AuthorityStore contract.
*/
export class PostgreSqlAuthorityService {
readonly #dependencies: PostgreSqlAuthorityServiceDependencies;

constructor(dependencies: PostgreSqlAuthorityServiceDependencies) {
this.#dependencies = dependencies;
}

async openStore(
request: PostgreSqlAuthorityServiceOpenRequest,
): Promise<PostgreSqlAuthorityServiceOpenResult> {
let tenantId: string;
let goalId: string;
if (
typeof request !== "object" || request === null ||
typeof request.tenant_id !== "string" ||
typeof request.goal_id !== "string" ||
typeof request.store_identity !== "string"
) {
return rejected("invalid_service_request", "PostgreSQL service request is invalid");
}
try {
tenantId = requireAuthorityStoreId(request.tenant_id, "tenant id");
goalId = requireAuthorityStoreId(request.goal_id, "goal id");
} catch (error) {
return rejected(
"invalid_service_request",
error instanceof Error ? error.message : "PostgreSQL service request is invalid",
);
}
if (!validIdentity(request.store_identity)) {
return rejected(
"invalid_service_request",
"PostgreSQL store identity must match postgresql:<32 lowercase hex>",
{tenant_id: tenantId, goal_id: goalId},
);
}

let authentication: PostgreSqlPrincipalAuthenticationResult;
try {
authentication = await this.#dependencies.authenticatePrincipal(request.credential);
} catch {
return rejected(
"principal_verification_unavailable",
"PostgreSQL service could not verify the principal",
{tenant_id: tenantId, goal_id: goalId},
);
}
if (authentication.status !== "authenticated") {
return rejected(
"principal_unauthenticated",
"PostgreSQL service principal authentication was rejected",
{tenant_id: tenantId, goal_id: goalId},
);
}

let principalId: string;
try {
principalId = requireAuthorityStoreId(
authentication.principal.principal_id,
"principal id",
);
} catch {
return rejected(
"principal_unauthenticated",
"PostgreSQL service returned an invalid authenticated principal",
{tenant_id: tenantId, goal_id: goalId},
);
}

let tenantDecision: PostgreSqlTenantAuthorizationResult;
try {
tenantDecision = await this.#dependencies.authorizeTenant(principalId, tenantId);
} catch {
return rejected(
"tenant_authorization_unavailable",
"PostgreSQL service could not authorize the tenant",
{principal_id: principalId, tenant_id: tenantId, goal_id: goalId},
);
}
if (tenantDecision.status !== "allowed") {
return rejected(
"tenant_unauthorized",
"PostgreSQL principal is not authorized for the requested tenant",
{principal_id: principalId, tenant_id: tenantId, goal_id: goalId},
);
}

const store = new PostgreSqlAuthorityStore(this.#dependencies.database, {
tenant_id: tenantId,
goal_id: goalId,
max_commit_bytes: this.#dependencies.max_commit_bytes,
});
const identity = await store.storeIdentity();
if (identity.status !== "available") {
return rejected(
"store_identity_unavailable",
"PostgreSQL service could not verify the database incarnation",
{principal_id: principalId, tenant_id: tenantId, goal_id: goalId},
);
}
if (identity.store_identity !== request.store_identity) {
return rejected(
"store_identity_mismatch",
"PostgreSQL database incarnation does not match the requested binding",
{principal_id: principalId, tenant_id: tenantId, goal_id: goalId},
);
}
return {
status: "opened",
provider: "postgresql",
principal_id: principalId,
tenant_id: tenantId,
goal_id: goalId,
store_identity: identity.store_identity,
store,
};
}
}
Loading