Skip to content
Merged
74 changes: 74 additions & 0 deletions .changeset/inline-credentials-refused-at-publish.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
---
"@objectstack/spec": major
"@objectstack/example-showcase": patch
---

feat(spec)!: refuse inline credentials at publish — driver `config.password` / `config.authToken` and connector `authentication` on authored entries (#7990)

`sys_metadata.metadata` is served back by the ordinary data API, and a datasource or
connector artefact is persisted whole — so any schema that *accepted* an inline
credential stored that credential in cleartext at rest. The maintainer-ruled fix
(#7990, Option A: per-artefact contract closure) makes the two measured surfaces
refuse the inline form at publish and divert to the mechanisms that already exist.

**Driver config (postgres / mysql / mongo / turso).** `config.password` (SQL/mongo)
and `config.authToken` (turso) are now declared-unwritable: writing one fails `tsc`
(the input type is `never`) and fails the parse with a prescription naming the
replacement. The former alias spellings (`passwd`, `pwd`, `token`, `jwt`,
`auth_token`, `authtoken`) carry the same refusal. The connection form's masked
secret input is unaffected — it never wrote `config`; it feeds the datasource secret
binder, which encrypts into `sys_secret` and stores only an opaque handle.

**Connector authoring door.** `DeclarativeConnectorEntrySchema` (behind
`defineStack({ connectors })` and `PUT /meta/connector/:name`) now refuses a
non-`none` `authentication` on **every** authored entry — catalog descriptors
included. Until now only provider-bound instances were covered (ADR-0097 §3), so a
descriptor could publish an inline `token`/`key`/`password`/`clientSecret`. The
runtime shape is unchanged: a plugin handing resolved secrets to
`registerConnector` keeps working.

## FROM → TO

```ts
// before — accepted, stored in cleartext in sys_metadata
defineDatasource({
name: 'warehouse', driver: 'postgres',
config: { database: 'analytics', username: 'ro', password: 'hunter2' },
})

// after — the secret lives in the secret store; config carries no credential
defineDatasource({
name: 'warehouse', driver: 'postgres', schemaMode: 'external',
config: { database: 'analytics', username: 'ro' },
external: { allowWrites: false, credentialsRef: 'sys_secret:<handle>' },
})
// (Setup → Datasources binds the secret for you: its password field encrypts into
// sys_secret and writes external.credentialsRef — it never wrote config.)
```

```ts
// before — descriptor published an inline credential
defineConnector({
name: 'erp', label: 'ERP', type: 'saas',
authentication: { type: 'api-key', key: '…', headerName: 'X-API-Key' },
})

// after — descriptor: no live credentials (document the scheme in prose);
defineConnector({ name: 'erp', label: 'ERP', type: 'saas',
description: 'Authenticates with an API key in the X-API-Key header.' })
// instance: reference the credential (ADR-0097 §3)
defineConnector({ name: 'erp', label: 'ERP', type: 'saas', provider: 'openapi',
providerConfig: { spec: './erp-openapi.json' },
auth: { type: 'api-key', credentialRef: 'ERP_API_KEY' } })
```

There is deliberately **no automatic rewrite**: moving a cleartext credential into
`sys_secret` requires encrypting it through a running secret binder, which a
source-file transform cannot do — auto-deleting the key would silently drop a live
credential instead. `os migrate meta` surfaces both changes as structured TODOs
(semantic entries `datasource-config-inline-credential-refused`,
`connector-inline-authentication-publish-refused`). The migration story for
**already-stored** cleartext rows is programme scope, tracked as a follow-up card
under #7990 — this release closes the doors that keep writing new ones.

<!-- adr-0087: registered datasource-config-inline-credential-refused, connector-inline-authentication-publish-refused -->
2 changes: 1 addition & 1 deletion content/docs/references/data/driver-mongo.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -46,7 +46,7 @@ MongoDB Connection Configuration
| **host** | `string` | ✅ | Host address |
| **port** | `integer` | ✅ | Port number |
| **username** | `string` | optional | Authentication user |
| **password** | `string` | optional | Authentication password (prefer external.credentialsRef) |
| **password** | `never` | optional | Set through the connection form's secret field or `external.credentialsRef` — encrypted into `sys_secret`, never stored in `config` (#7990) |
| **authSource** | `string` | optional | Authentication database |
| **options** | `Record<string, any>` | optional | Extra MongoClient options (replicaSet, tls, timeouts, …) |

Expand Down
2 changes: 1 addition & 1 deletion content/docs/references/data/driver-mysql.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -47,7 +47,7 @@ MySQL / MariaDB connection configuration
| **port** | `integer` | ✅ | Port number |
| **database** | `string` | optional | Database name |
| **username** | `string` | optional | Authentication user |
| **password** | `string` | optional | Authentication password (prefer external.credentialsRef) |
| **password** | `never` | optional | Set through the connection form's secret field or `external.credentialsRef` — encrypted into `sys_secret`, never stored in `config` (#7990) |
| **ssl** | `boolean` | optional | Enable TLS. Certificates go in the datasource-level `ssl` block. |
| **autoMigrate** | `Enum<'off' \| 'safe'>` | optional | Dev-only non-destructive schema self-heal (#2186) |

Expand Down
2 changes: 1 addition & 1 deletion content/docs/references/data/driver-postgres.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -45,7 +45,7 @@ PostgreSQL connection configuration
| **port** | `integer` | ✅ | Port number |
| **database** | `string` | optional | Database name |
| **username** | `string` | optional | Authentication user |
| **password** | `string` | optional | Authentication password (prefer external.credentialsRef) |
| **password** | `never` | optional | Set through the connection form's secret field or `external.credentialsRef` — encrypted into `sys_secret`, never stored in `config` (#7990) |
| **ssl** | `boolean` | optional | Enable TLS. Certificates go in the datasource-level `ssl` block. |
| **schema** | `string` | ✅ | Default schema (knex searchPath) |
| **applicationName** | `string` | optional | Postgres application_name |
Expand Down
2 changes: 1 addition & 1 deletion content/docs/references/data/driver-turso.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -66,7 +66,7 @@ Turso / libSQL Connection Configuration
| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **url** | `string` | ✅ | libSQL endpoint or local file: a remote libsql/https Turso URL, a file path, or :memory: |
| **authToken** | `string` | optional | JWT auth token for a remote libSQL database (prefer external.credentialsRef) |
| **authToken** | `never` | optional | Set through the connection form's secret field or `external.credentialsRef` — encrypted into `sys_secret`, never stored in `config` (#7990) |
| **encryptionKey** | `string` | optional | AES-256 encryption key for the local database file (local/replica modes) |
| **concurrency** | `integer` | optional | Maximum concurrent requests to the remote database |
| **syncUrl** | `string` | optional | Remote sync URL for embedded-replica mode: a libsql or https Turso endpoint |
Expand Down
4 changes: 2 additions & 2 deletions content/docs/references/integration/connector.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -166,7 +166,7 @@ Circuit breaker configuration
| **type** | `Enum<'saas' \| 'database' \| 'file_storage' \| 'message_queue' \| 'api' \| 'custom'>` | ✅ | Connector type |
| **description** | `string` | optional | Connector description |
| **icon** | `string` | optional | Icon identifier |
| **authentication** | `{ type: 'oauth2'; authorizationUrl: string; tokenUrl: string; clientId: string; … } \| { type: 'api-key'; key: string; headerName?: string; paramName?: string } \| { type: 'basic'; username: string; password: string } \| { type: 'bearer'; token: string } \| { type: 'none' }` | optional | Authentication configuration (runtime shape with inline secrets). Provider-bound declarative instances use `auth.credentialRef` instead. |
| **authentication** | `{ type: 'oauth2'; authorizationUrl: string; tokenUrl: string; clientId: string; … } \| { type: 'api-key'; key: string; headerName?: string; paramName?: string } \| { type: 'basic'; username: string; password: string } \| { type: 'bearer'; token: string } \| { type: 'none' }` | optional | Authentication configuration (runtime shape with inline secrets — plugin-supplied at registerConnector). Authored entries must not inline secrets (#7990): use `auth.credentialRef` on a provider-bound instance. |
| **provider** | `string` | optional | Generic-executor key that materializes this declarative entry at boot (e.g. openapi/mcp/rest). Omit for a catalog-only descriptor. Unknown provider ⇒ hard boot error (ADR-0097). |
| **providerConfig** | `Record<string, any>` | optional | Provider-specific config validated by the provider factory at boot (e.g. `{ spec, baseUrl }` for openapi, where spec is an inline document, a package-relative file path like './billing-openapi.json', or an http(s) URL). Requires `provider`. |
| **auth** | `{ type: 'none' } \| { type: 'bearer'; credentialRef: string } \| { type: 'api-key'; credentialRef: string; headerName?: string; paramName?: string } \| { type: 'basic'; username: string; credentialRef: string }` | optional | Declarative instance auth — references credentials via `credentialRef` (resolved at boot), never inline secrets. Requires `provider` (ADR-0097). |
Expand DownExpand Up@@ -488,7 +488,7 @@ Connector type
| **type** | `Enum<'saas' \| 'database' \| 'file_storage' \| 'message_queue' \| 'api' \| 'custom'>` | ✅ | Connector type |
| **description** | `string` | optional | Connector description |
| **icon** | `string` | optional | Icon identifier |
| **authentication** | `{ type: 'oauth2'; authorizationUrl: string; tokenUrl: string; clientId: string; … } \| { type: 'api-key'; key: string; headerName?: string; paramName?: string } \| { type: 'basic'; username: string; password: string } \| { type: 'bearer'; token: string } \| { type: 'none' }` | optional | Authentication configuration (runtime shape with inline secrets). Provider-bound declarative instances use `auth.credentialRef` instead. |
| **authentication** | `{ type: 'oauth2'; authorizationUrl: string; tokenUrl: string; clientId: string; … } \| { type: 'api-key'; key: string; headerName?: string; paramName?: string } \| { type: 'basic'; username: string; password: string } \| { type: 'bearer'; token: string } \| { type: 'none' }` | optional | Authentication configuration (runtime shape with inline secrets — plugin-supplied at registerConnector). Authored entries must not inline secrets (#7990): use `auth.credentialRef` on a provider-bound instance. |
| **provider** | `string` | optional | Generic-executor key that materializes this declarative entry at boot (e.g. openapi/mcp/rest). Omit for a catalog-only descriptor. Unknown provider ⇒ hard boot error (ADR-0097). |
| **providerConfig** | `Record<string, any>` | optional | Provider-specific config validated by the provider factory at boot (e.g. `{ spec, baseUrl }` for openapi, where spec is an inline document, a package-relative file path like './billing-openapi.json', or an http(s) URL). Requires `provider`. |
| **auth** | `{ type: 'none' } \| { type: 'bearer'; credentialRef: string } \| { type: 'api-key'; credentialRef: string; headerName?: string; paramName?: string } \| { type: 'basic'; username: string; credentialRef: string }` | optional | Declarative instance auth — references credentials via `credentialRef` (resolved at boot), never inline secrets. Requires `provider` (ADR-0097). |
Expand Down
Loading
Loading