|
| 1 | +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +import { ObjectSchema, Field } from '@objectstack/spec/data'; |
| 4 | + |
| 5 | +/** |
| 6 | + * sys_sso_provider — Registered external SSO identity provider (OIDC / SAML) |
| 7 | + * |
| 8 | + * Backed by `@better-auth/sso`'s `ssoProvider` model. Each row is an external |
| 9 | + * IdP that THIS environment federates LOGIN to (the relying-party / client |
| 10 | + * side) — e.g. the customer's Okta / Entra / Google Workspace. This is the |
| 11 | + * per-environment SSO **mechanism** (ADR-0024): OPEN, configured in the env, |
| 12 | + * and cloud-free for self-host. |
| 13 | + * |
| 14 | + * better-auth stores the protocol detail as a JSON blob in `oidc_config` |
| 15 | + * (OIDC: clientId, clientSecret, endpoints, scopes, mapping, pkce, …) or |
| 16 | + * `saml_config` (SAML: entryPoint, cert, identifierFormat, mapping, …) — not |
| 17 | + * as separate columns. Field set mirrors `@better-auth/sso@1.6.20`'s |
| 18 | + * `BaseSSOProvider`: issuer, oidcConfig, samlConfig, userId, providerId, |
| 19 | + * organizationId, domain. |
| 20 | + * |
| 21 | + * All mutations route through better-auth's `/api/v1/auth/sso/*` endpoints |
| 22 | + * (register / delete-provider) so config validation and secret handling run; |
| 23 | + * the generic data layer is read-only (see `enable.apiMethods`). |
| 24 | + * |
| 25 | + * @namespace sys |
| 26 | + */ |
| 27 | +export const SysSsoProvider = ObjectSchema.create({ |
| 28 | + name: 'sys_sso_provider', |
| 29 | + label: 'SSO Provider', |
| 30 | + pluralLabel: 'SSO Providers', |
| 31 | + icon: 'shield-check', |
| 32 | + isSystem: true, |
| 33 | + managedBy: 'better-auth', |
| 34 | + // ADR-0010 §3.7 — managed by better-auth; tenants may not edit schema. |
| 35 | + protection: { |
| 36 | + lock: 'full', |
| 37 | + reason: 'Identity table managed by better-auth (@better-auth/sso) — see ADR-0024.', |
| 38 | + docsUrl: 'https://docs.objectstack.ai/adr/0010-metadata-protection', |
| 39 | + }, |
| 40 | + description: 'External SSO identity providers (OIDC / SAML) this environment federates login to', |
| 41 | + displayNameField: 'provider_id', |
| 42 | + titleFormat: '{provider_id}', |
| 43 | + compactLayout: ['provider_id', 'issuer', 'domain'], |
| 44 | + |
| 45 | + // All mutations go through @better-auth/sso's endpoints under |
| 46 | + // /api/v1/auth/sso/* (register / delete-provider) rather than the generic |
| 47 | + // data layer, so server-side config validation + secret handling run. |
| 48 | + actions: [ |
| 49 | + { |
| 50 | + name: 'register_sso_provider', |
| 51 | + label: 'Register SSO Provider', |
| 52 | + icon: 'plus-circle', |
| 53 | + variant: 'primary', |
| 54 | + mode: 'create', |
| 55 | + locations: ['list_toolbar'], |
| 56 | + type: 'api', |
| 57 | + method: 'POST', |
| 58 | + target: '/api/v1/auth/sso/register', |
| 59 | + refreshAfter: true, |
| 60 | + params: [ |
| 61 | + { name: 'providerId', label: 'Provider ID', type: 'text', required: true, helpText: 'Stable identifier, e.g. "okta" or "acme-entra".' }, |
| 62 | + { name: 'issuer', label: 'Issuer URL', type: 'text', required: true, helpText: 'IdP issuer / discovery base, e.g. https://acme.okta.com.' }, |
| 63 | + { name: 'domain', label: 'Email Domain', type: 'text', required: true, helpText: 'Users with this email domain are routed to this IdP.' }, |
| 64 | + { name: 'clientId', label: 'Client ID', type: 'text', required: true }, |
| 65 | + { name: 'clientSecret', label: 'Client Secret', type: 'text', required: true }, |
| 66 | + ], |
| 67 | + }, |
| 68 | + { |
| 69 | + name: 'delete_sso_provider', |
| 70 | + label: 'Delete SSO Provider', |
| 71 | + icon: 'trash-2', |
| 72 | + variant: 'danger', |
| 73 | + mode: 'delete', |
| 74 | + locations: ['list_item', 'record_header'], |
| 75 | + type: 'api', |
| 76 | + method: 'POST', |
| 77 | + target: '/api/v1/auth/sso/delete-provider', |
| 78 | + confirmText: 'Delete this SSO provider? Users from its domain will no longer be able to sign in through it.', |
| 79 | + successMessage: 'SSO provider deleted', |
| 80 | + refreshAfter: true, |
| 81 | + params: [ |
| 82 | + { name: 'providerId', field: 'provider_id', defaultFromRow: true, required: true }, |
| 83 | + ], |
| 84 | + }, |
| 85 | + ], |
| 86 | + |
| 87 | + listViews: { |
| 88 | + all: { |
| 89 | + type: 'grid', |
| 90 | + name: 'all', |
| 91 | + label: 'All', |
| 92 | + data: { provider: 'object', object: 'sys_sso_provider' }, |
| 93 | + columns: ['provider_id', 'issuer', 'domain', 'created_at'], |
| 94 | + sort: [{ field: 'provider_id', order: 'asc' }], |
| 95 | + pagination: { pageSize: 50 }, |
| 96 | + }, |
| 97 | + }, |
| 98 | + |
| 99 | + fields: { |
| 100 | + id: Field.text({ label: 'ID', required: true, readonly: true, group: 'System' }), |
| 101 | + |
| 102 | + provider_id: Field.text({ |
| 103 | + label: 'Provider ID', |
| 104 | + required: true, |
| 105 | + searchable: true, |
| 106 | + maxLength: 255, |
| 107 | + description: 'Stable provider identifier (unique within the environment)', |
| 108 | + group: 'Identity', |
| 109 | + }), |
| 110 | + |
| 111 | + issuer: Field.text({ |
| 112 | + label: 'Issuer', |
| 113 | + required: true, |
| 114 | + maxLength: 2048, |
| 115 | + description: 'IdP issuer URL', |
| 116 | + group: 'Identity', |
| 117 | + }), |
| 118 | + |
| 119 | + domain: Field.text({ |
| 120 | + label: 'Email Domain', |
| 121 | + required: true, |
| 122 | + maxLength: 255, |
| 123 | + description: 'Email domain routed to this IdP (e.g. acme.com)', |
| 124 | + group: 'Identity', |
| 125 | + }), |
| 126 | + |
| 127 | + oidc_config: Field.textarea({ |
| 128 | + label: 'OIDC Config', |
| 129 | + required: false, |
| 130 | + description: 'JSON: clientId, clientSecret, endpoints, scopes, mapping, pkce (managed by better-auth)', |
| 131 | + group: 'Protocol', |
| 132 | + }), |
| 133 | + |
| 134 | + saml_config: Field.textarea({ |
| 135 | + label: 'SAML Config', |
| 136 | + required: false, |
| 137 | + description: 'JSON: entryPoint, cert, identifierFormat, mapping (managed by better-auth)', |
| 138 | + group: 'Protocol', |
| 139 | + }), |
| 140 | + |
| 141 | + user_id: Field.lookup('sys_user', { |
| 142 | + label: 'Registered By', |
| 143 | + required: false, |
| 144 | + description: 'User who registered this provider', |
| 145 | + group: 'System', |
| 146 | + }), |
| 147 | + |
| 148 | + organization_id: Field.text({ |
| 149 | + label: 'Organization', |
| 150 | + required: false, |
| 151 | + maxLength: 255, |
| 152 | + description: 'Organization scope (when org-scoped SSO is used)', |
| 153 | + group: 'System', |
| 154 | + }), |
| 155 | + |
| 156 | + created_at: Field.datetime({ label: 'Created At', defaultValue: 'NOW()', readonly: true, group: 'System' }), |
| 157 | + updated_at: Field.datetime({ label: 'Updated At', defaultValue: 'NOW()', readonly: true, group: 'System' }), |
| 158 | + }, |
| 159 | + |
| 160 | + indexes: [ |
| 161 | + { fields: ['provider_id'], unique: true }, |
| 162 | + { fields: ['domain'] }, |
| 163 | + { fields: ['user_id'] }, |
| 164 | + ], |
| 165 | + |
| 166 | + enable: { |
| 167 | + trackHistory: true, |
| 168 | + searchable: true, |
| 169 | + apiEnabled: true, |
| 170 | + // Mutations go through /api/v1/auth/sso/* (register / delete-provider); |
| 171 | + // the generic data layer is read-only so sysadmins cannot bypass |
| 172 | + // server-side validation / secret handling. |
| 173 | + apiMethods: ['get', 'list'], |
| 174 | + trash: false, |
| 175 | + mru: false, |
| 176 | + }, |
| 177 | +}); |
0 commit comments