@segmentstream-cloud/ontology 0.23.0 → 0.24.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +16 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/manifest.d.ts +5 -0
- package/dist/manifest.js +2 -0
- package/dist/migrate/apply.js +2 -1
- package/dist/migrate/model.d.ts +2 -0
- package/dist/migrate/plan.js +9 -1
- package/dist/migrate/storage.d.ts +3 -0
- package/dist/migrate/storage.js +10 -0
- package/dist/permissions.d.ts +3 -0
- package/dist/permissions.js +8 -1
- package/dist/runtime/history.d.ts +22 -0
- package/dist/runtime/history.js +2 -0
- package/dist/runtime/pipeline.js +6 -2
- package/dist/runtime/principals.d.ts +4 -1
- package/dist/runtime/principals.js +141 -63
- package/dist/runtime/reads.js +9 -1
- package/dist/runtime/service-binding.js +2 -1
- package/dist/runtime/sql-runtime.d.ts +12 -2
- package/dist/runtime/sql-runtime.js +33 -0
- package/dist/runtime/workload.d.ts +23 -0
- package/dist/runtime/workload.js +22 -0
- package/dist/runtime/writes.js +7 -1
- package/dist/runtime.d.ts +3 -0
- package/dist/service/authentication.d.ts +11 -0
- package/dist/service/authentication.js +114 -5
- package/dist/service/config.d.ts +4 -0
- package/dist/service/config.js +11 -0
- package/dist/service/handlers.js +2 -1
- package/dist/service/mcp/server.js +5 -4
- package/dist/service/operations.d.ts +25 -5
- package/dist/service/runtime-contract.d.ts +1 -0
- package/dist/service/schemas.d.ts +122 -0
- package/dist/storage/attachments.js +4 -4
- package/dist/storage/authorization.d.ts +1 -1
- package/dist/storage/authorization.js +9 -4
- package/dist/storage/sessions.js +3 -3
- package/dist/storage/writes.js +1 -1
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -500,6 +500,22 @@ usage error.
|
|
|
500
500
|
`ontology/index.ts`, migrates the workspace database and deploys the service under
|
|
501
501
|
`/api/ontology`. A failure stops the deployment and keeps the running version.
|
|
502
502
|
|
|
503
|
+
## Development tests
|
|
504
|
+
|
|
505
|
+
`npm test --workspace @segmentstream-cloud/ontology` runs the ordinary suite with
|
|
506
|
+
embedded PGlite. Authentication contention tests require separate PostgreSQL
|
|
507
|
+
backends and run against a local disposable PostgreSQL server:
|
|
508
|
+
|
|
509
|
+
```sh
|
|
510
|
+
ONTOLOGY_TEST_ADMIN_URL=postgresql://postgres@127.0.0.1:5432/postgres \
|
|
511
|
+
npm run test:postgres --workspace @segmentstream-cloud/ontology
|
|
512
|
+
```
|
|
513
|
+
|
|
514
|
+
The test fixture accepts only a localhost `postgres` admin connection to the
|
|
515
|
+
`postgres` database. It creates a randomly named test database, applies the example
|
|
516
|
+
ontology, and drops that database on completion. Repository CI and the SDK release
|
|
517
|
+
gate run this suite with PostgreSQL 17.
|
|
518
|
+
|
|
503
519
|
## License
|
|
504
520
|
|
|
505
521
|
Apache-2.0
|
package/dist/index.d.ts
CHANGED
|
@@ -53,6 +53,7 @@ export { makeOntologyRuntime } from "./runtime/sql-runtime.js";
|
|
|
53
53
|
export type { SqlOntologyRuntime, WriteScope } from "./runtime/sql-runtime.js";
|
|
54
54
|
export { BASE_PATH, deriveApi } from "./service/api.js";
|
|
55
55
|
export type { DeriveApiOptions, OntologyApi } from "./service/api.js";
|
|
56
|
+
export { WorkloadExecution } from "./runtime/workload.js";
|
|
56
57
|
export { CurrentPrincipal } from "./service/authentication.js";
|
|
57
58
|
export { deployedConnections, LocalConnectionContextError, localConnections, OIDC_TOKEN_HEADER, parseLocalConnectionContext, readLocalConnectionContext, RUNTIME_CONTEXT_FD, } from "./service/connections.js";
|
|
58
59
|
export type { ConnectionAudit, ConnectionTransportFactory, LocalConnection, LocalConnectionContext, } from "./service/connections.js";
|
package/dist/index.js
CHANGED
|
@@ -31,6 +31,7 @@ export { backendLayer, postgresLayer, sqlServiceRuntime } from "./runtime/servic
|
|
|
31
31
|
export { makeOntologyRuntime } from "./runtime/sql-runtime.js";
|
|
32
32
|
// The HTTP service derived from the ontology.
|
|
33
33
|
export { BASE_PATH, deriveApi } from "./service/api.js";
|
|
34
|
+
export { WorkloadExecution } from "./runtime/workload.js";
|
|
34
35
|
export { CurrentPrincipal } from "./service/authentication.js";
|
|
35
36
|
export { deployedConnections, LocalConnectionContextError, localConnections, OIDC_TOKEN_HEADER, parseLocalConnectionContext, readLocalConnectionContext, RUNTIME_CONTEXT_FD, } from "./service/connections.js";
|
|
36
37
|
export { configFromEnv, AGENT_HOST_VARIABLE, ENVIRONMENT_VARIABLES, NEON_AUTH_ENVIRONMENT_VARIABLES, OntologyServiceConfigError, RUNTIME_IDENTITY_ENVIRONMENT_VARIABLES, } from "./service/config.js";
|
package/dist/manifest.d.ts
CHANGED
|
@@ -449,6 +449,7 @@ export declare const OntologyManifestWire: Schema.encodeKeys<Schema.Struct<{
|
|
|
449
449
|
}>, {
|
|
450
450
|
readonly builtIn: "built_in";
|
|
451
451
|
}>>;
|
|
452
|
+
readonly runtimeCapabilities: Schema.optionalKey<Schema.$Array<Schema.String>>;
|
|
452
453
|
readonly version: Schema.Int;
|
|
453
454
|
/** `sha256:` and the hex digest of the canonical JSON of the manifest without `hash`. */
|
|
454
455
|
readonly hash: Schema.String;
|
|
@@ -619,6 +620,7 @@ export declare const OntologyManifest: Schema.Union<readonly [Schema.encodeKeys<
|
|
|
619
620
|
}>, {
|
|
620
621
|
readonly builtIn: "built_in";
|
|
621
622
|
}>>;
|
|
623
|
+
readonly runtimeCapabilities: Schema.optionalKey<Schema.$Array<Schema.String>>;
|
|
622
624
|
readonly version: Schema.Int;
|
|
623
625
|
/** `sha256:` and the hex digest of the canonical JSON of the manifest without `hash`. */
|
|
624
626
|
readonly hash: Schema.String;
|
|
@@ -653,6 +655,7 @@ export declare const OntologyManifest: Schema.Union<readonly [Schema.encodeKeys<
|
|
|
653
655
|
}>, {
|
|
654
656
|
readonly signIn: "sign_in";
|
|
655
657
|
}>, Schema.Struct<{
|
|
658
|
+
readonly runtimeCapabilities: Schema.optionalKey<Schema.$Array<Schema.String>>;
|
|
656
659
|
readonly version: Schema.Int;
|
|
657
660
|
/** `sha256:` and the hex digest of the canonical JSON of the manifest without `hash`. */
|
|
658
661
|
readonly hash: Schema.String;
|
|
@@ -929,6 +932,7 @@ export declare const decodeManifestDocument: (value: unknown) => Effect.Effect<{
|
|
|
929
932
|
readonly key: string;
|
|
930
933
|
readonly description: string;
|
|
931
934
|
}[] | undefined;
|
|
935
|
+
readonly runtimeCapabilities?: readonly string[] | undefined;
|
|
932
936
|
readonly signIn?: {
|
|
933
937
|
readonly policy: string;
|
|
934
938
|
} | undefined;
|
|
@@ -1047,6 +1051,7 @@ export declare const decodeManifestDocument: (value: unknown) => Effect.Effect<{
|
|
|
1047
1051
|
readonly key: string;
|
|
1048
1052
|
readonly description: string;
|
|
1049
1053
|
}[] | undefined;
|
|
1054
|
+
readonly runtimeCapabilities?: readonly string[] | undefined;
|
|
1050
1055
|
readonly signIn?: {
|
|
1051
1056
|
readonly policy: string;
|
|
1052
1057
|
} | undefined;
|
package/dist/manifest.js
CHANGED
|
@@ -213,6 +213,7 @@ export const SignInManifest = Schema.Struct({
|
|
|
213
213
|
* `onDelete`) are validated because the migration planner must map every value to storage.
|
|
214
214
|
*/
|
|
215
215
|
const OntologyManifestModel = Schema.Struct({
|
|
216
|
+
runtimeCapabilities: Schema.optionalKey(Schema.Array(Schema.String)),
|
|
216
217
|
version: Schema.Int,
|
|
217
218
|
/** `sha256:` and the hex digest of the canonical JSON of the manifest without `hash`. */
|
|
218
219
|
hash: Schema.String,
|
|
@@ -462,6 +463,7 @@ export function buildManifest(input) {
|
|
|
462
463
|
.map((candidate) => candidate.name));
|
|
463
464
|
const body = {
|
|
464
465
|
version: MANIFEST_VERSION,
|
|
466
|
+
runtimeCapabilities: ["pipeline-workload-tokens-v1"],
|
|
465
467
|
entities: input.entities.map((candidate) => entityManifest({ resolved: candidate, warehouseBacked })),
|
|
466
468
|
roles: input.roles.map((candidate) => ({
|
|
467
469
|
name: candidate.name,
|
package/dist/migrate/apply.js
CHANGED
|
@@ -7,7 +7,7 @@ import { REPLICATION_DDL, REPLICATION_SCHEMA_VERSION, replicationEntityDdl, } fr
|
|
|
7
7
|
import { replicationMigrationRefusals } from "../replication/migrate.js";
|
|
8
8
|
import { ATTACHMENTS_TABLE } from "../storage/tables.js";
|
|
9
9
|
import { resolveReplicationSelection, } from "../replication/model.js";
|
|
10
|
-
import { AGENT_TABLES, API_KEYS_TABLE, CATALOG_TABLE, OAUTH_TABLES, USER_ROLES_TABLE, quoteIdentifier, quoteLiteral, } from "./storage.js";
|
|
10
|
+
import { ACTION_ATTRIBUTION_TABLE, AGENT_TABLES, API_KEYS_TABLE, CATALOG_TABLE, OAUTH_TABLES, USER_ROLES_TABLE, quoteIdentifier, quoteLiteral, } from "./storage.js";
|
|
11
11
|
const CATALOG = quoteIdentifier(CATALOG_TABLE);
|
|
12
12
|
/** Serializes migrations of one database; transaction-scoped, so it ends with the transaction. */
|
|
13
13
|
const MIGRATION_LOCK = "segmentstream.ontology.migration";
|
|
@@ -180,6 +180,7 @@ export const applyMigration = (plan, options) => Effect.gen(function* () {
|
|
|
180
180
|
*/
|
|
181
181
|
/** The runtime's tables, beside the catalog and the entity tables. */
|
|
182
182
|
export const RUNTIME_TABLES = [
|
|
183
|
+
ACTION_ATTRIBUTION_TABLE,
|
|
183
184
|
USER_ROLES_TABLE,
|
|
184
185
|
API_KEYS_TABLE,
|
|
185
186
|
...OAUTH_TABLES,
|
package/dist/migrate/model.d.ts
CHANGED
|
@@ -152,6 +152,7 @@ export declare const MigrationPlan: Schema.Struct<{
|
|
|
152
152
|
}>, {
|
|
153
153
|
readonly builtIn: "built_in";
|
|
154
154
|
}>>;
|
|
155
|
+
readonly runtimeCapabilities: Schema.optionalKey<Schema.$Array<Schema.String>>;
|
|
155
156
|
readonly version: Schema.Int;
|
|
156
157
|
readonly hash: Schema.String;
|
|
157
158
|
readonly signIn: Schema.optionalKey<Schema.Struct<{
|
|
@@ -174,6 +175,7 @@ export declare const MigrationPlan: Schema.Struct<{
|
|
|
174
175
|
}>, {
|
|
175
176
|
readonly signIn: "sign_in";
|
|
176
177
|
}>, Schema.Struct<{
|
|
178
|
+
readonly runtimeCapabilities: Schema.optionalKey<Schema.$Array<Schema.String>>;
|
|
177
179
|
readonly version: Schema.Int;
|
|
178
180
|
readonly hash: Schema.String;
|
|
179
181
|
readonly entities: Schema.$Array<Schema.Struct<{
|
package/dist/migrate/plan.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { isValueMatched, } from "../manifest.js";
|
|
2
2
|
import { ATTACHMENTS_TABLE, ATTACHMENT_TABLES_DDL, BLOB_TABLES_DDL } from "../storage/tables.js";
|
|
3
3
|
import { SNAKE_CASE_NAME, isSystemProperty, toSnakeCase } from "../names.js";
|
|
4
|
-
import { AGENT_TABLES, AGENT_TABLES_DDL, CATALOG_TABLE_DDL, MAX_IDENTIFIER_LENGTH, OAUTH_TABLES, OAUTH_TABLES_DDL, USER_TABLE, addConstraintStatement, columnDefinition, constraintDefinition, createEnumStatement, createIndexStatement, createTableStatement, defaultExpression, entityStorage, enumTypeName, fieldColumnName, joinTable, propertyCheck, blobForeignKey, propertyColumn, propertyEnum, quoteIdentifier, quoteLiteral, relationshipColumn, RUNTIME_TABLES_DDL, relationshipForeignKey, SYSTEM_COLUMNS, tableName, } from "./storage.js";
|
|
4
|
+
import { ACTION_ATTRIBUTION_TABLE, ACTION_ATTRIBUTION_DDL, AGENT_TABLES, AGENT_TABLES_DDL, CATALOG_TABLE_DDL, MAX_IDENTIFIER_LENGTH, OAUTH_TABLES, OAUTH_TABLES_DDL, USER_TABLE, addConstraintStatement, columnDefinition, constraintDefinition, createEnumStatement, createIndexStatement, createTableStatement, defaultExpression, entityStorage, enumTypeName, fieldColumnName, joinTable, propertyCheck, blobForeignKey, propertyColumn, propertyEnum, quoteIdentifier, quoteLiteral, relationshipColumn, RUNTIME_TABLES_DDL, relationshipForeignKey, SYSTEM_COLUMNS, tableName, } from "./storage.js";
|
|
5
5
|
const GLOBAL_KINDS = new Set(["table", "type", "index"]);
|
|
6
6
|
function step(input) {
|
|
7
7
|
return {
|
|
@@ -77,6 +77,14 @@ export function planMigration(input) {
|
|
|
77
77
|
else {
|
|
78
78
|
// A database an earlier SDK migrated lacks the tables added since; each set arrives on its own.
|
|
79
79
|
const lacks = (tables) => tables.some((table) => input.existingTables?.has(table) === false);
|
|
80
|
+
if (lacks([ACTION_ATTRIBUTION_TABLE])) {
|
|
81
|
+
draft.runtimeTables.push(step({
|
|
82
|
+
kind: "createCatalog",
|
|
83
|
+
classification: "additive",
|
|
84
|
+
description: "Create the runtime's verified pipeline action attribution table.",
|
|
85
|
+
sql: ACTION_ATTRIBUTION_DDL,
|
|
86
|
+
}));
|
|
87
|
+
}
|
|
80
88
|
if (lacks(OAUTH_TABLES)) {
|
|
81
89
|
draft.runtimeTables.push(step({
|
|
82
90
|
kind: "createCatalog",
|
|
@@ -180,6 +180,9 @@ export declare const AGENT_TABLES_DDL: ReadonlyArray<string>;
|
|
|
180
180
|
* The runtime's own tables beside the catalog. They are created with it, after the entity tables
|
|
181
181
|
* because they refer to the built-in user table, and every statement is idempotent.
|
|
182
182
|
*/
|
|
183
|
+
export declare const ACTION_ATTRIBUTION_TABLE = "_ontology_action_attribution";
|
|
184
|
+
/** Runtime audit metadata deliberately has no foreign key: deleted records retain their history. */
|
|
185
|
+
export declare const ACTION_ATTRIBUTION_DDL: ReadonlyArray<string>;
|
|
183
186
|
export declare const RUNTIME_TABLES_DDL: ReadonlyArray<string>;
|
|
184
187
|
export declare function columnDefinition(column: ColumnStorage): string;
|
|
185
188
|
export declare function constraintDefinition(constraint: {
|
package/dist/migrate/storage.js
CHANGED
|
@@ -575,7 +575,17 @@ export const AGENT_TABLES_DDL = [
|
|
|
575
575
|
* The runtime's own tables beside the catalog. They are created with it, after the entity tables
|
|
576
576
|
* because they refer to the built-in user table, and every statement is idempotent.
|
|
577
577
|
*/
|
|
578
|
+
export const ACTION_ATTRIBUTION_TABLE = "_ontology_action_attribution";
|
|
579
|
+
/** Runtime audit metadata deliberately has no foreign key: deleted records retain their history. */
|
|
580
|
+
export const ACTION_ATTRIBUTION_DDL = [
|
|
581
|
+
`CREATE TABLE IF NOT EXISTS "${ACTION_ATTRIBUTION_TABLE}" (
|
|
582
|
+
"action_id" uuid PRIMARY KEY,
|
|
583
|
+
"execution" jsonb NOT NULL,
|
|
584
|
+
"at" timestamptz NOT NULL DEFAULT now()
|
|
585
|
+
)`,
|
|
586
|
+
];
|
|
578
587
|
export const RUNTIME_TABLES_DDL = [
|
|
588
|
+
...ACTION_ATTRIBUTION_DDL,
|
|
579
589
|
[
|
|
580
590
|
`CREATE TABLE IF NOT EXISTS ${quoteIdentifier(USER_ROLES_TABLE)} (`,
|
|
581
591
|
` "user_id" uuid NOT NULL,`,
|
package/dist/permissions.d.ts
CHANGED
|
@@ -33,6 +33,7 @@ export declare function permissionOf(entity: string, verb: string): string;
|
|
|
33
33
|
export declare function grants(granted: string, required: string): boolean;
|
|
34
34
|
/** Anything that holds permissions: a principal, or the reader of a catalog. */
|
|
35
35
|
export interface PermissionHolder {
|
|
36
|
+
readonly execution?: import("./runtime/workload.js").WorkloadExecution;
|
|
36
37
|
readonly permissions: ReadonlyArray<string>;
|
|
37
38
|
}
|
|
38
39
|
/**
|
|
@@ -40,6 +41,8 @@ export interface PermissionHolder {
|
|
|
40
41
|
* through a wildcard.
|
|
41
42
|
*/
|
|
42
43
|
export declare function isAllowed(required: string, principal: PermissionHolder): boolean;
|
|
44
|
+
/** Expand workload authority for permission-only catalog and documentation readers. */
|
|
45
|
+
export declare function effectivePermissions(principal: PermissionHolder): ReadonlyArray<string>;
|
|
43
46
|
/** The first of the required permissions the principal lacks, or `undefined` when it holds all. */
|
|
44
47
|
export declare function missingPermission(required: ReadonlyArray<string>, principal: PermissionHolder): string | undefined;
|
|
45
48
|
/**
|
package/dist/permissions.js
CHANGED
|
@@ -52,7 +52,14 @@ export function grants(granted, required) {
|
|
|
52
52
|
* through a wildcard.
|
|
53
53
|
*/
|
|
54
54
|
export function isAllowed(required, principal) {
|
|
55
|
-
return principal.permissions.
|
|
55
|
+
return ((principal.execution !== undefined && principal.permissions.includes("*:*")) ||
|
|
56
|
+
principal.permissions.some((granted) => grants(granted, required)));
|
|
57
|
+
}
|
|
58
|
+
/** Expand workload authority for permission-only catalog and documentation readers. */
|
|
59
|
+
export function effectivePermissions(principal) {
|
|
60
|
+
return principal.execution !== undefined && principal.permissions.includes("*:*")
|
|
61
|
+
? [...principal.permissions, ...Array.from(EXPLICIT_GRANT_ENTITIES, (entity) => `${entity}:*`)]
|
|
62
|
+
: principal.permissions;
|
|
56
63
|
}
|
|
57
64
|
/** The first of the required permissions the principal lacks, or `undefined` when it holds all. */
|
|
58
65
|
export function missingPermission(required, principal) {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { Schema } from "effect";
|
|
2
|
+
import { WorkloadExecution } from "./workload.js";
|
|
2
3
|
import type { StoredRecord } from "../runtime.js";
|
|
3
4
|
import type { EntityModel } from "./model.js";
|
|
4
5
|
import type { Params } from "./statement.js";
|
|
@@ -31,6 +32,26 @@ export declare const Edit: Schema.Struct<{
|
|
|
31
32
|
export type Edit = typeof Edit.Type;
|
|
32
33
|
/** A row of `<entity>_history`. */
|
|
33
34
|
export declare const HistoryEntry: Schema.Struct<{
|
|
35
|
+
readonly execution: Schema.optionalKey<Schema.Struct<{
|
|
36
|
+
readonly issuer: Schema.String;
|
|
37
|
+
readonly audience: Schema.String;
|
|
38
|
+
readonly organizationId: Schema.String;
|
|
39
|
+
readonly workspaceId: Schema.String;
|
|
40
|
+
readonly subject: Schema.String;
|
|
41
|
+
readonly deploymentId: Schema.String;
|
|
42
|
+
readonly ontologyDeploymentId: Schema.String;
|
|
43
|
+
readonly servingOntologyDeploymentId: Schema.String;
|
|
44
|
+
readonly ontologyOrigin: Schema.String;
|
|
45
|
+
readonly environment: Schema.Literal<"production">;
|
|
46
|
+
readonly jobName: Schema.String;
|
|
47
|
+
readonly runId: Schema.String;
|
|
48
|
+
readonly executionId: Schema.NullOr<Schema.String>;
|
|
49
|
+
readonly attemptId: Schema.String;
|
|
50
|
+
readonly generation: Schema.Number;
|
|
51
|
+
readonly tokenId: Schema.String;
|
|
52
|
+
readonly issuedAt: Schema.Number;
|
|
53
|
+
readonly expiresAt: Schema.Number;
|
|
54
|
+
}>>;
|
|
34
55
|
readonly id: Schema.String;
|
|
35
56
|
readonly recordId: Schema.String;
|
|
36
57
|
/** The qualified action, for example `lead.changeStatus`. */
|
|
@@ -54,6 +75,7 @@ export interface HistoryWrite {
|
|
|
54
75
|
readonly edit: Edit;
|
|
55
76
|
}
|
|
56
77
|
export interface HistoryScope {
|
|
78
|
+
readonly execution?: WorkloadExecution;
|
|
57
79
|
readonly action: string;
|
|
58
80
|
readonly actionId: string;
|
|
59
81
|
readonly principalId: string | null;
|
package/dist/runtime/history.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Schema } from "effect";
|
|
2
2
|
import { canonicalJson } from "../manifest.js";
|
|
3
|
+
import { WorkloadExecution } from "./workload.js";
|
|
3
4
|
import { quoteIdentifier } from "../migrate/storage.js";
|
|
4
5
|
import { uuidv7 } from "./ids.js";
|
|
5
6
|
export const EditKind = Schema.Literals(["create", "update", "delete", "link", "unlink"]);
|
|
@@ -21,6 +22,7 @@ export const Edit = Schema.Struct({
|
|
|
21
22
|
});
|
|
22
23
|
/** A row of `<entity>_history`. */
|
|
23
24
|
export const HistoryEntry = Schema.Struct({
|
|
25
|
+
execution: Schema.optionalKey(WorkloadExecution),
|
|
24
26
|
id: Schema.String,
|
|
25
27
|
recordId: Schema.String,
|
|
26
28
|
/** The qualified action, for example `lead.changeStatus`. */
|
package/dist/runtime/pipeline.js
CHANGED
|
@@ -141,7 +141,7 @@ export function runAction(ontology, request) {
|
|
|
141
141
|
if (consumesUpload) {
|
|
142
142
|
const saved = yield* sql
|
|
143
143
|
.withTransaction(Effect.gen(function* () {
|
|
144
|
-
yield* authorizeBlobOperation(ontology, sql, request.principal.id, found.permissions);
|
|
144
|
+
yield* authorizeBlobOperation(ontology, sql, request.principal.id, found.permissions, request.principal.execution);
|
|
145
145
|
const [saved] = yield* sql.unsafe(`SELECT fingerprint, outcome FROM "_blob_action_results"
|
|
146
146
|
WHERE principal_id = $1::uuid AND key = $2`, [request.principal.id, key]);
|
|
147
147
|
if (saved && saved.fingerprint !== fingerprint) {
|
|
@@ -168,6 +168,7 @@ export function runAction(ontology, request) {
|
|
|
168
168
|
action: qualified,
|
|
169
169
|
actionId,
|
|
170
170
|
principalId: request.principal.id,
|
|
171
|
+
...(request.principal.execution ? { execution: request.principal.execution } : {}),
|
|
171
172
|
...(key ? { blobActionKey: key } : {}),
|
|
172
173
|
...(request.branchId ? { branchId: request.branchId } : {}),
|
|
173
174
|
...(request.options?.createIntent ? { createIntent: request.options.createIntent } : {}),
|
|
@@ -177,8 +178,11 @@ export function runAction(ontology, request) {
|
|
|
177
178
|
const handler = Effect.suspend(() => handle({ ...context, prepared }));
|
|
178
179
|
let replay;
|
|
179
180
|
const transactionalHandler = Effect.gen(function* () {
|
|
181
|
+
if (request.principal.execution !== undefined) {
|
|
182
|
+
yield* authorizeBlobOperation(ontology, sql, request.principal.id, found.permissions, request.principal.execution);
|
|
183
|
+
}
|
|
180
184
|
if (consumesUpload) {
|
|
181
|
-
yield* authorizeBlobOperation(ontology, sql, request.principal.id, found.permissions);
|
|
185
|
+
yield* authorizeBlobOperation(ontology, sql, request.principal.id, found.permissions, request.principal.execution);
|
|
182
186
|
yield* sql.unsafe(`INSERT INTO "_blob_action_results" (principal_id, key, fingerprint)
|
|
183
187
|
VALUES ($1::uuid, $2, $3) ON CONFLICT DO NOTHING`, [request.principal.id, key, fingerprint]);
|
|
184
188
|
const [saved] = yield* sql.unsafe(`SELECT fingerprint, outcome FROM "_blob_action_results" WHERE principal_id = $1::uuid AND key = $2
|
|
@@ -46,9 +46,12 @@ export declare const SIGN_IN_RECORD_INTERVAL_MS: number;
|
|
|
46
46
|
* is linked by email when someone invited them; a person nobody invited is rejected as
|
|
47
47
|
* `not_invited` under the `invited` policy and becomes a member holding only `member` under
|
|
48
48
|
* `open`. Every sign-in that lets the member in sets `lastSignInAt`, at most once per
|
|
49
|
-
* {@link SIGN_IN_RECORD_INTERVAL_MS}, so history keeps it.
|
|
49
|
+
* {@link SIGN_IN_RECORD_INTERVAL_MS}, so history keeps it. If the User row is busy, recording is
|
|
50
|
+
* deferred to a later request instead of delaying authentication. Inactive users are rejected.
|
|
50
51
|
*/
|
|
51
52
|
export declare function resolvePrincipalByIdentity(ontology: Ontology, claims: IdentityClaims): Effect.Effect<Principal, PrincipalRejected, SqlClient.SqlClient>;
|
|
53
|
+
/** Resolve the workspace's stable keyless pipeline service, without customer roles or sign-in. */
|
|
54
|
+
export declare function resolvePipelineWorker(ontology: Ontology, execution: import("./workload.js").WorkloadExecution): Effect.Effect<Principal, PrincipalRejected, SqlClient.SqlClient>;
|
|
52
55
|
/**
|
|
53
56
|
* The principal of an API key, with the permissions its roles have in the ontology. Unknown and
|
|
54
57
|
* revoked keys and inactive users are rejected.
|
|
@@ -15,7 +15,7 @@ import { beginReplicationWrite } from "../replication/transaction.js";
|
|
|
15
15
|
import { User } from "../user.js";
|
|
16
16
|
import { MemberNotFound, PrincipalRejected, UnknownRole } from "./errors.js";
|
|
17
17
|
import { isRecordId } from "./records.js";
|
|
18
|
-
import { makeOntologyRuntime } from "./sql-runtime.js";
|
|
18
|
+
import { makeOntologyRuntime, makePrincipalUserWriter, } from "./sql-runtime.js";
|
|
19
19
|
/** API keys are `ssk_<key id>.<secret>`. */
|
|
20
20
|
export const API_KEY_PREFIX = "ssk_";
|
|
21
21
|
/**
|
|
@@ -107,93 +107,116 @@ function userRow(record) {
|
|
|
107
107
|
return decodeUser({ id: record.id, kind: record.kind, active: record.active });
|
|
108
108
|
}
|
|
109
109
|
/**
|
|
110
|
-
* Finds
|
|
110
|
+
* Finds the member's user without locking. Links or creates it transactionally when needed. A
|
|
111
111
|
* user linked to this identity is found; else a member invited with the same email is linked; else
|
|
112
112
|
* the ontology's sign-in policy decides: `invited` turns the person away, `open` creates a member.
|
|
113
113
|
*/
|
|
114
114
|
function signIn(ontology, sql, claims) {
|
|
115
|
-
const runtime =
|
|
115
|
+
const runtime = makeOntologyRuntime(ontology, sql);
|
|
116
|
+
const writer = makePrincipalUserWriter(ontology, sql, {
|
|
117
|
+
action: "user.sign_in",
|
|
118
|
+
actionId: randomUUID(),
|
|
119
|
+
});
|
|
116
120
|
const claimedEmail = claims.email?.trim().toLowerCase();
|
|
117
121
|
const email = claimedEmail !== undefined && EMAIL_PATTERN.test(claimedEmail) ? claimedEmail : undefined;
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
entity: "user",
|
|
125
|
-
options: {
|
|
126
|
-
where: {
|
|
127
|
-
identity_provider: claims.identityProvider,
|
|
128
|
-
identity_subject: claims.identitySubject,
|
|
129
|
-
},
|
|
130
|
-
limit: 1,
|
|
131
|
-
forUpdate: true,
|
|
122
|
+
const findLinked = (forUpdate = false) => runtime.find({
|
|
123
|
+
entity: "user",
|
|
124
|
+
options: {
|
|
125
|
+
where: {
|
|
126
|
+
identity_provider: claims.identityProvider,
|
|
127
|
+
identity_subject: claims.identitySubject,
|
|
132
128
|
},
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
129
|
+
limit: 1,
|
|
130
|
+
forUpdate,
|
|
131
|
+
},
|
|
132
|
+
});
|
|
133
|
+
const member = (record) => record.kind === "member"
|
|
134
|
+
? Effect.succeed(userRow(record))
|
|
135
|
+
: Effect.fail(new PrincipalRejected({
|
|
136
|
+
reason: "identity_conflict",
|
|
137
|
+
message: "This identity belongs to a service, not a member.",
|
|
138
|
+
}));
|
|
139
|
+
return Effect.gen(function* () {
|
|
140
|
+
const [known] = yield* findLinked();
|
|
141
|
+
if (known !== undefined) {
|
|
142
|
+
const user = yield* member(known);
|
|
143
|
+
// Ordinary authentication is read-only. Optional bookkeeping must never queue behind a
|
|
144
|
+
// business transaction holding a User key or row lock; a later request can record it.
|
|
145
|
+
if (!user.active || !writer.recordsSignIn || !signInDue(known.last_sign_in_at, new Date())) {
|
|
146
|
+
return user;
|
|
138
147
|
}
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
148
|
+
return yield* sql.withTransaction(Effect.gen(function* () {
|
|
149
|
+
const [locked] = yield* sql.unsafe(`SELECT "id" FROM "${USER_TABLE}" WHERE "id" = $1::uuid FOR UPDATE SKIP LOCKED`, [user.id]);
|
|
150
|
+
if (locked === undefined) {
|
|
151
|
+
return user;
|
|
152
|
+
}
|
|
153
|
+
const current = yield* runtime.get({ entity: "user", id: user.id });
|
|
154
|
+
const principal = yield* member(current);
|
|
155
|
+
const now = new Date();
|
|
156
|
+
if (principal.active && signInDue(current.last_sign_in_at, now)) {
|
|
157
|
+
yield* writer.update(user.id, { last_sign_in_at: now.toISOString() });
|
|
158
|
+
}
|
|
159
|
+
return principal;
|
|
160
|
+
}));
|
|
145
161
|
}
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
if (invited !== undefined) {
|
|
153
|
-
if (invited.kind !== "member" || invited.identity_subject !== null) {
|
|
154
|
-
return yield* new PrincipalRejected({
|
|
155
|
-
reason: "identity_conflict",
|
|
156
|
-
message: "Another user already has this email.",
|
|
157
|
-
});
|
|
162
|
+
return yield* sql.withTransaction(Effect.gen(function* () {
|
|
163
|
+
const now = new Date();
|
|
164
|
+
const signedIn = writer.recordsSignIn ? { last_sign_in_at: now.toISOString() } : {};
|
|
165
|
+
const [linked] = yield* findLinked(true);
|
|
166
|
+
if (linked !== undefined) {
|
|
167
|
+
return yield* member(linked);
|
|
158
168
|
}
|
|
159
|
-
const
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
169
|
+
const [invited] = email === undefined
|
|
170
|
+
? []
|
|
171
|
+
: yield* runtime.find({
|
|
172
|
+
entity: "user",
|
|
173
|
+
options: { where: { email }, limit: 1, forUpdate: true },
|
|
174
|
+
});
|
|
175
|
+
if (invited !== undefined) {
|
|
176
|
+
// A concurrent first sign-in may have linked this invitation while this transaction
|
|
177
|
+
// waited for its row. Accept the exact identity; another identity still conflicts.
|
|
178
|
+
if (invited.identity_provider === claims.identityProvider &&
|
|
179
|
+
invited.identity_subject === claims.identitySubject) {
|
|
180
|
+
return yield* member(invited);
|
|
181
|
+
}
|
|
182
|
+
if (invited.kind !== "member" || invited.identity_subject !== null) {
|
|
183
|
+
return yield* new PrincipalRejected({
|
|
184
|
+
reason: "identity_conflict",
|
|
185
|
+
message: "Another user already has this email.",
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
const linkedUser = yield* writer.update(invited.id, {
|
|
163
189
|
identity_provider: claims.identityProvider,
|
|
164
190
|
identity_subject: claims.identitySubject,
|
|
165
191
|
...(invited.active === true ? signedIn : {}),
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
const created = yield* runtime.create({
|
|
177
|
-
entity: "user",
|
|
178
|
-
values: {
|
|
192
|
+
});
|
|
193
|
+
return userRow(linkedUser);
|
|
194
|
+
}
|
|
195
|
+
if (ontology.signIn.policy !== "open") {
|
|
196
|
+
return yield* new PrincipalRejected({
|
|
197
|
+
reason: "not_invited",
|
|
198
|
+
message: "No member was invited with this identity or email.",
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
const created = yield* writer.create({
|
|
179
202
|
name: claims.name?.trim() || email || claims.identitySubject,
|
|
180
203
|
email: email ?? null,
|
|
181
204
|
kind: "member",
|
|
182
205
|
identity_provider: claims.identityProvider,
|
|
183
206
|
identity_subject: claims.identitySubject,
|
|
184
207
|
...signedIn,
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
}))
|
|
189
|
-
.pipe(Effect.catchTag("NotFound", (error) => Effect.die(error)));
|
|
208
|
+
});
|
|
209
|
+
return userRow(created);
|
|
210
|
+
}));
|
|
211
|
+
}).pipe(Effect.catchTag("NotFound", (error) => Effect.die(error)));
|
|
190
212
|
}
|
|
191
213
|
/**
|
|
192
214
|
* The principal of a member signed in with a verified token. On first sign-in the member's `user`
|
|
193
215
|
* is linked by email when someone invited them; a person nobody invited is rejected as
|
|
194
216
|
* `not_invited` under the `invited` policy and becomes a member holding only `member` under
|
|
195
217
|
* `open`. Every sign-in that lets the member in sets `lastSignInAt`, at most once per
|
|
196
|
-
* {@link SIGN_IN_RECORD_INTERVAL_MS}, so history keeps it.
|
|
218
|
+
* {@link SIGN_IN_RECORD_INTERVAL_MS}, so history keeps it. If the User row is busy, recording is
|
|
219
|
+
* deferred to a later request instead of delaying authentication. Inactive users are rejected.
|
|
197
220
|
*/
|
|
198
221
|
export function resolvePrincipalByIdentity(ontology, claims) {
|
|
199
222
|
return Effect.gen(function* () {
|
|
@@ -207,6 +230,61 @@ export function resolvePrincipalByIdentity(ontology, claims) {
|
|
|
207
230
|
return yield* activePrincipal(ontology, sql, user);
|
|
208
231
|
}).pipe(Effect.catchIf(isSqlError, (error) => Effect.die(error)));
|
|
209
232
|
}
|
|
233
|
+
/** Resolve the workspace's stable keyless pipeline service, without customer roles or sign-in. */
|
|
234
|
+
export function resolvePipelineWorker(ontology, execution) {
|
|
235
|
+
return Effect.gen(function* () {
|
|
236
|
+
const sql = yield* SqlClient.SqlClient;
|
|
237
|
+
const provider = "segmentstream-ontology-workload";
|
|
238
|
+
const subject = `pipeline-workers:${execution.workspaceId}`;
|
|
239
|
+
const runtime = platformRuntime(ontology, sql, "user.resolve_pipeline_worker");
|
|
240
|
+
const principal = (user) => user.kind !== "service" || user.active !== true
|
|
241
|
+
? Effect.fail(new PrincipalRejected({
|
|
242
|
+
reason: "inactive_user",
|
|
243
|
+
message: "The pipeline worker principal is inactive or has an invalid kind.",
|
|
244
|
+
}))
|
|
245
|
+
: Effect.succeed({
|
|
246
|
+
id: user.id,
|
|
247
|
+
kind: "service",
|
|
248
|
+
roles: [],
|
|
249
|
+
permissions: ["*:*"],
|
|
250
|
+
execution,
|
|
251
|
+
});
|
|
252
|
+
const find = (forUpdate) => runtime.find({
|
|
253
|
+
entity: "user",
|
|
254
|
+
options: {
|
|
255
|
+
where: { identity_provider: provider, identity_subject: subject },
|
|
256
|
+
limit: 1,
|
|
257
|
+
forUpdate,
|
|
258
|
+
},
|
|
259
|
+
});
|
|
260
|
+
const [known] = yield* find(false);
|
|
261
|
+
// Returning requests only read the principal; writes revalidate it in their transaction.
|
|
262
|
+
if (known !== undefined) {
|
|
263
|
+
return yield* principal(known);
|
|
264
|
+
}
|
|
265
|
+
return yield* sql
|
|
266
|
+
.withTransaction(Effect.gen(function* () {
|
|
267
|
+
// The replication lock precedes every business lock, including principal initialization.
|
|
268
|
+
yield* beginReplicationWrite(sql);
|
|
269
|
+
yield* sql.unsafe("SELECT pg_advisory_xact_lock(hashtextextended($1, 0))", [
|
|
270
|
+
provider + ":" + subject,
|
|
271
|
+
]);
|
|
272
|
+
const [existing] = yield* find(true);
|
|
273
|
+
const user = existing ??
|
|
274
|
+
(yield* runtime.create({
|
|
275
|
+
entity: "user",
|
|
276
|
+
values: {
|
|
277
|
+
name: "Pipeline workers",
|
|
278
|
+
kind: "service",
|
|
279
|
+
identity_provider: provider,
|
|
280
|
+
identity_subject: subject,
|
|
281
|
+
},
|
|
282
|
+
}));
|
|
283
|
+
return yield* principal(user);
|
|
284
|
+
}))
|
|
285
|
+
.pipe(Effect.catchTag("Conflict", (error) => Effect.die(error)), Effect.catchTag("NotFound", (error) => Effect.die(error)));
|
|
286
|
+
}).pipe(Effect.catchIf(isSqlError, (error) => Effect.die(error)));
|
|
287
|
+
}
|
|
210
288
|
/**
|
|
211
289
|
* The principal of an API key, with the permissions its roles have in the ontology. Unknown and
|
|
212
290
|
* revoked keys and inactive users are rejected.
|
package/dist/runtime/reads.js
CHANGED
|
@@ -238,17 +238,25 @@ export function readHistory(context, entity, input) {
|
|
|
238
238
|
const at = params.add(cursor[0], "timestamptz");
|
|
239
239
|
conditions.push(`("h"."at" < ${at} OR ("h"."at" = ${at} AND "h"."id" < ${params.add(cursor[1], "uuid")}))`);
|
|
240
240
|
}
|
|
241
|
+
const presenceParams = new Params();
|
|
242
|
+
const [presence] = yield* context.run("SELECT to_regclass('_ontology_action_attribution') IS NOT NULL AS present", presenceParams);
|
|
243
|
+
const attribution = presence?.present === true;
|
|
241
244
|
const rows = yield* context.run(`SELECT "h"."id"::text AS "id", "h"."record_id"::text AS "recordId", "h"."action" AS "action", ` +
|
|
242
245
|
`"h"."action_id"::text AS "actionId", "h"."kind" AS "kind", ` +
|
|
243
246
|
`"h"."principal_id"::text AS "principalId", ` +
|
|
244
247
|
`to_char("h"."at" AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.US"Z"') AS "at", ` +
|
|
245
248
|
`"h"."changes"::text AS "changes" ` +
|
|
249
|
+
(attribution ? `, "a"."execution" AS "execution" ` : "") +
|
|
246
250
|
`FROM ${quoteIdentifier(entity.historyTable)} AS "h" ` +
|
|
251
|
+
(attribution
|
|
252
|
+
? `LEFT JOIN "_ontology_action_attribution" AS "a" ON "a"."action_id" = "h"."action_id" `
|
|
253
|
+
: "") +
|
|
247
254
|
`WHERE ${conditions.join(" AND ")} ` +
|
|
248
255
|
`ORDER BY "h"."at" DESC, "h"."id" DESC ` +
|
|
249
256
|
`LIMIT ${params.add(limit + 1, "bigint")}`, params);
|
|
250
257
|
const items = rows.slice(0, limit).map((row) => Schema.decodeUnknownSync(HistoryEntry)({
|
|
251
|
-
...row,
|
|
258
|
+
...Object.fromEntries(Object.entries(row).filter(([key, value]) => key !== "execution" || value != null)),
|
|
259
|
+
...(row.execution == null ? {} : { execution: row.execution }),
|
|
252
260
|
at: canonicalInstant(String(row.at)),
|
|
253
261
|
changes: JSON.parse(String(row.changes)),
|
|
254
262
|
}));
|
|
@@ -11,7 +11,7 @@ import { sqlAgentStore } from "./agents.js";
|
|
|
11
11
|
import { generatedActions } from "./generated-actions.js";
|
|
12
12
|
import { sqlOAuthStore } from "./oauth.js";
|
|
13
13
|
import { runAction } from "./pipeline.js";
|
|
14
|
-
import { checkSignIn, resolvePrincipalByApiKey, resolvePrincipalByIdentity } from "./principals.js";
|
|
14
|
+
import { checkSignIn, resolvePrincipalByApiKey, resolvePrincipalByIdentity, resolvePipelineWorker, } from "./principals.js";
|
|
15
15
|
import { postgresTls } from "./postgres-tls.js";
|
|
16
16
|
import { makeOntologyRuntime } from "./sql-runtime.js";
|
|
17
17
|
/** Connections one service instance holds to the database's pooler. */
|
|
@@ -61,6 +61,7 @@ export function backendLayer(ontology, blobStorage) {
|
|
|
61
61
|
})).pipe(Effect.catchTag("PrincipalRejected", (rejection) => rejection.reason === "not_invited"
|
|
62
62
|
? rejectedAs("sign_in")(rejection).pipe(Effect.andThen(Effect.fail(new NotInvited({ message: rejection.message }))))
|
|
63
63
|
: rejectedAs("sign_in")(rejection))),
|
|
64
|
+
resolvePipelineWorker: (execution) => withSql(resolvePipelineWorker(ontology, execution)).pipe(Effect.catchTag("PrincipalRejected", rejectedAs("pipeline_worker"))),
|
|
64
65
|
checkSignIn: (email) => withSql(checkSignIn(ontology, email)),
|
|
65
66
|
resolveApiKey: (key) => withSql(resolvePrincipalByApiKey(ontology, Redacted.value(key))).pipe(Effect.catchTag("PrincipalRejected", rejectedAs("api_key"))),
|
|
66
67
|
appliedHash: readCatalog({ sql }).pipe(Effect.map((manifest) => manifest?.hash), Effect.orDie),
|