mirror of
https://github.com/coder/coder.git
synced 2026-09-23 05:43:53 +08:00
`POST /oauth2/register` (RFC 7591 Dynamic Client Registration) has exactly one gate today: `ExperimentOAuth2`, a static, process-lifetime flag that wraps the entire `/oauth2/*` route tree as an all-or-nothing switch. That flag is scheduled for removal at GA, which would leave DCR with zero admin control at all once it is gone. Add a persistent, DCR-specific `oauth2_dcr_enabled` deployment setting, independent of the experiment system, so admin control over DCR survives GA. `POST /oauth2/register` checks the flag and rejects new registrations with an RFC 7591-shaped `403` when disabled; discovery metadata (`GET /.well-known/oauth-authorization-server`) conditionally omits `registration_endpoint`. A new audited `GET`/`PUT /api/v2/oauth2-provider/settings` endpoint lets an owner toggle it live, no restart required. The setting defaults to disabled, matching the canonical design proposal; disabling only stops new self-registrations, clients that already registered continue to authorize and exchange tokens normally. Address issue described in [ENG-3056](https://linear.app/codercom/issue/ENG-3056/oauth2-dcr-admin-configurable-enabledisable). ## Where this sits in the request path ```mermaid sequenceDiagram autonumber participant A as Admin participant S as coderd participant DB as site_configs<br/>(oauth2_dcr_enabled) participant C as OAuth2/MCP Client Note over A,S: Admin toggles DCR (new) A->>S: PUT /api/v2/oauth2-provider/settings<br/>{dynamic_client_registration_enabled: false} S->>S: authorizeContext(ActionUpdate, ResourceDeploymentConfig) S->>DB: UPSERT oauth2_dcr_enabled = false S-->>A: 200 OK (audited) Note over C,S: Client discovery + registration afterward C->>S: GET /.well-known/oauth-authorization-server S->>DB: GetOAuth2DCREnabled (system ctx, every request, no cache) DB-->>S: false S-->>C: 200 metadata, registration_endpoint omitted C->>S: POST /oauth2/register S->>DB: GetOAuth2DCREnabled (system ctx, every request, no cache) DB-->>S: false S-->>C: 403 invalid_request,<br/>"Dynamic client registration is disabled" Note over C,S: A client that registered before the change is unaffected C->>S: GET /oauth2/authorize?client_id=... Note over S: no DCR-enabled check on this path S-->>C: 200 (proceeds normally) C->>S: PUT/DELETE /oauth2/clients/{client_id} (RFC 7592 self-management) Note over S: no DCR-enabled check on this path either S-->>C: 200 (proceeds normally) ``` ## Files changed: manual vs. generated Reviewers should focus on the **manual** files. The **generated** ones are `make gen` output that follows mechanically from the manual changes and don't need direct review. <details> <summary><b>Manual files (26)</b> — click to expand, grouped the same way as "Suggested review order" below</summary> **1. Database** | File | What changed | |---|---| | `coderd/database/queries/siteconfig.sql` | New `GetOAuth2DCREnabled`/`UpsertOAuth2DCREnabled` query pair on the existing generic `site_configs` table. No schema change. | | `coderd/database/dbauthz/dbauthz.go` | RBAC check (`rbac.ResourceDeploymentConfig`) on the two new query methods; extends the `subjectSystemOAuth2` system-actor role with read-only `ResourceDeploymentConfig` access, needed so the public discovery/registration endpoints can read the flag via `dbauthz.AsSystemOAuth2`. | | `coderd/database/dbauthz/dbauthz_test.go` | RBAC assertion coverage for `GetOAuth2DCREnabled`/`UpsertOAuth2DCREnabled` in the method-coverage test suite. | **2. Request gating (the actual feature)** | File | What changed | |---|---| | `coderd/oauth2provider/registration.go` | The actual gate: `CreateDynamicClientRegistration` reads the flag first and returns an RFC 7591-shaped `403` when disabled (defaults disabled if never configured). | | `coderd/oauth2provider/registration_test.go` | New unit test, `TestCreateDynamicClientRegistration_DCREnabled`: calls the handler directly (no HTTP server), covering enabled / explicitly disabled / never-configured. | | `coderd/oauth2provider/metadata.go` | `GetAuthorizationServerMetadata` conditionally omits `registration_endpoint` from discovery metadata when DCR is disabled. | | `coderd/oauth2provider/metadata_test.go` | New unit test, `TestGetAuthorizationServerMetadata_DCREnabled`: same three states, for the discovery handler. | **3. Admin settings endpoint** | File | What changed | |---|---| | `codersdk/oauth2.go` | New `OAuth2ProviderSettings` SDK type plus `Client.OAuth2ProviderSettings`/`PutOAuth2ProviderSettings` methods. | | `coderd/oauth2.go` | New `oauth2ProviderSettings`/`putOAuth2ProviderSettings` admin handlers (audited via `audit.InitRequest`); updates the `GetAuthorizationServerMetadata` call site to pass `api.Database`. | | `coderd/coderd.go` | Registers `GET`/`PUT /api/v2/oauth2-provider/settings`. | | `coderd/oauth2_provider_settings_test.go` | New test file: admin `GET`/`PUT` round-trip, default-disabled-before-any-`PUT`, and `403` for a non-owner on both `GET` and `PUT`. | **4. Audit wiring** | File | What changed | |---|---| | `coderd/database/types.go` | New `database.OAuth2ProviderSettings` audit-only struct (mirrors `NotificationsSettings`). | | `coderd/audit/diff.go` | Adds the new struct to the `Auditable` type union. | | `coderd/audit/request.go` | Adds the new struct to all four dispatch switches (`ResourceTarget`, `ResourceID`, `ResourceType`, `ResourceRequiresOrgID`). | | `codersdk/audit.go` | New API-facing `ResourceTypeOAuth2ProviderSettings` constant and its `FriendlyString` case. | | `enterprise/audit/table.go` | Field-level audit action map (`ActionTrack`/`ActionIgnore`) for the new struct. | | `coderd/database/migrations/000546_audit_oauth2_provider_settings.up.sql` | Adds `oauth2_provider_settings` to the `resource_type` Postgres enum, required for the audit wiring above (`resource_type` is a real enum, not a Go-only value). | | `coderd/database/migrations/000546_audit_oauth2_provider_settings.down.sql` | No-op (`ALTER TYPE ... ADD VALUE` can't be reverted). | **5. Test-suite ripple from the disabled-by-default flip** | File | What changed | |---|---| | `coderd/oauth2provider/oauth2providertest/helpers.go` | New shared test helper, `EnableDCR`, since DCR now defaults to disabled and many pre-existing tests need it turned on to register a client. | | `coderd/oauth2_test.go` | Adds `TestOAuth2DynamicClientRegistrationDisabled` (registers a client, disables DCR, verifies new registration is rejected while the existing client's self-management, authorize, and token exchange all keep working); calls `EnableDCR` in every pre-existing test that registers a client. | | `coderd/oauth2_error_compliance_test.go` | Calls `EnableDCR` in every test that registers a client, so RFC-error-format assertions aren't masked by the new disabled-by-default gate. | | `coderd/oauth2_metadata_validation_test.go` | Same: `EnableDCR` added to every registration-dependent test. | | `coderd/oauth2_security_test.go` | Same. | | `coderd/oauth2provider/validation_test.go` | Same (near-duplicate of `oauth2_metadata_validation_test.go` in a different package). | | `coderd/oauth2provider/provider_test.go` | Same. | | `coderd/mcp/mcp_e2e_test.go` | Same, for the MCP end-to-end dynamic-registration flow test. | </details> <details> <summary><b>Generated files (12)</b> — from <code>make gen</code>, no need to review directly</summary> `coderd/apidoc/docs.go`, `coderd/apidoc/swagger.json`, `coderd/database/dbmetrics/querymetrics.go`, `coderd/database/dbmock/dbmock.go`, `coderd/database/dump.sql`, `coderd/database/models.go`, `coderd/database/querier.go`, `coderd/database/queries.sql.go`, `docs/admin/security/audit-logs.md`, `docs/reference/api/enterprise.md`, `docs/reference/api/schemas.md`, `site/src/api/typesGenerated.ts`. </details> ## Suggested review order ### 1. Database Establishes the persisted setting and its RBAC rule; everything else builds on `GetOAuth2DCREnabled`/`UpsertOAuth2DCREnabled`. 1. `coderd/database/queries/siteconfig.sql` — the two new queries. Same boolean-encoding pattern as the existing `oauth2_github_default_eligible` key right above them in the same file. 2. `coderd/database/dbauthz/dbauthz.go` — the RBAC wrapper for those two queries, plus the `subjectSystemOAuth2` role extension (search this file for `ResourceDeploymentConfig`, it appears in both spots). 3. `coderd/database/dbauthz/dbauthz_test.go` — asserts the RBAC checks from (2) actually fire. ### 2. Request gating (the actual feature) Where `POST /oauth2/register` and discovery metadata change behavior. 1. `coderd/oauth2provider/registration.go` — the primary gate. Read this first; it's the feature. 2. `coderd/oauth2provider/registration_test.go` — its new unit test, exercising the gate's three states directly against the handler. 3. `coderd/oauth2provider/metadata.go` — the same gating pattern applied to the discovery `GET` endpoint. 4. `coderd/oauth2provider/metadata_test.go` — its new unit test. ### 3. Admin settings endpoint How an owner flips the setting live. 1. `codersdk/oauth2.go` — the `OAuth2ProviderSettings` SDK type and `Client` methods first; this is the public contract everything below implements against. 2. `coderd/oauth2.go` — the `GET`/`PUT` handlers themselves. 3. `coderd/coderd.go` — route registration, to see where those handlers get wired in. 4. `coderd/oauth2_provider_settings_test.go` — round-trip and permission tests. ### 4. Audit wiring Plumbing required so step 3's `PUT` is auditable; mechanical except for (3). 1. `coderd/database/types.go` — the audit-only struct; everything else in this layer exists to plumb it through. 2. `coderd/audit/diff.go` — adds it to the `Auditable` type union (the compiler enforces this one). 3. `coderd/audit/request.go` — the four dispatch switches; the one part of this layer worth reading closely. 4. `codersdk/audit.go` — the API-facing resource type constant. 5. `enterprise/audit/table.go` — the field-action map. 6. `coderd/database/migrations/000546_audit_oauth2_provider_settings.{up,down}.sql` — read last; a consequence of needing a new `resource_type` enum value for (1)-(5), not a design decision of its own. ### 5. Test-suite ripple from the disabled-by-default flip 1. `coderd/oauth2provider/oauth2providertest/helpers.go` — the new `EnableDCR` helper. Read first to understand the fix pattern before seeing it applied repeatedly. 2. `coderd/oauth2_test.go` — next, since it also contains the new `TestOAuth2DynamicClientRegistrationDisabled`, not just `EnableDCR` call sites. 3. The rest, in any order, they're mechanical repeats of the same one-line addition: `coderd/oauth2_error_compliance_test.go`, `coderd/oauth2_metadata_validation_test.go`, `coderd/oauth2_security_test.go`, `coderd/oauth2provider/validation_test.go`, `coderd/oauth2provider/provider_test.go`, `coderd/mcp/mcp_e2e_test.go`. ## Explicitly out of scope Per the design proposal: rate limiting on `POST /oauth2/register` (tracked separately), retroactively affecting already-registered clients when DCR is disabled (this only gates new self-registration), and an Initial Access Token requirement (a separate, follow-up ticket).
434 lines
11 KiB
Go
434 lines
11 KiB
Go
package database
|
|
|
|
import (
|
|
"database/sql/driver"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/lib/pq"
|
|
"github.com/sqlc-dev/pqtype"
|
|
"golang.org/x/xerrors"
|
|
|
|
"github.com/coder/coder/v2/coderd/rbac"
|
|
"github.com/coder/coder/v2/coderd/rbac/policy"
|
|
)
|
|
|
|
// AuditOAuthConvertState is never stored in the database. It is stored in a cookie
|
|
// clientside as a JWT. This type is provided for audit logging purposes.
|
|
type AuditOAuthConvertState struct {
|
|
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
|
// The time at which the state string expires, a merge request times out if the user does not perform it quick enough.
|
|
ExpiresAt time.Time `db:"expires_at" json:"expires_at"`
|
|
FromLoginType LoginType `db:"from_login_type" json:"from_login_type"`
|
|
// The login type the user is converting to. Should be github or oidc.
|
|
ToLoginType LoginType `db:"to_login_type" json:"to_login_type"`
|
|
UserID uuid.UUID `db:"user_id" json:"user_id"`
|
|
}
|
|
|
|
type HealthSettings struct {
|
|
ID uuid.UUID `db:"id" json:"id"`
|
|
DismissedHealthchecks []string `db:"dismissed_healthchecks" json:"dismissed_healthchecks"`
|
|
}
|
|
|
|
type NotificationsSettings struct {
|
|
ID uuid.UUID `db:"id" json:"id"`
|
|
NotifierPaused bool `db:"notifier_paused" json:"notifier_paused"`
|
|
}
|
|
|
|
type PrebuildsSettings struct {
|
|
ID uuid.UUID `db:"id" json:"id"`
|
|
ReconciliationPaused bool `db:"reconciliation_paused" json:"reconciliation_paused"`
|
|
}
|
|
|
|
type OAuth2ProviderSettings struct {
|
|
ID uuid.UUID `db:"id" json:"id"`
|
|
DynamicClientRegistrationEnabled bool `db:"dynamic_client_registration_enabled" json:"dynamic_client_registration_enabled"`
|
|
}
|
|
|
|
type Actions []policy.Action
|
|
|
|
func (a *Actions) Scan(src interface{}) error {
|
|
switch v := src.(type) {
|
|
case string:
|
|
return json.Unmarshal([]byte(v), &a)
|
|
case []byte:
|
|
return json.Unmarshal(v, &a)
|
|
}
|
|
return xerrors.Errorf("unexpected type %T", src)
|
|
}
|
|
|
|
func (a *Actions) Value() (driver.Value, error) {
|
|
return json.Marshal(a)
|
|
}
|
|
|
|
// TemplateACL is a map of ids to permissions.
|
|
type TemplateACL map[string][]policy.Action
|
|
|
|
func (t *TemplateACL) Scan(src interface{}) error {
|
|
switch v := src.(type) {
|
|
case string:
|
|
return json.Unmarshal([]byte(v), &t)
|
|
case []byte:
|
|
return json.Unmarshal(v, &t)
|
|
case json.RawMessage:
|
|
return json.Unmarshal(v, &t)
|
|
}
|
|
|
|
return xerrors.Errorf("unexpected type %T", src)
|
|
}
|
|
|
|
func (t TemplateACL) Value() (driver.Value, error) {
|
|
return json.Marshal(t)
|
|
}
|
|
|
|
type ChatACL map[string]ChatACLEntry
|
|
|
|
func (c *ChatACL) Scan(src interface{}) error {
|
|
switch v := src.(type) {
|
|
case string:
|
|
return json.Unmarshal([]byte(v), &c)
|
|
case []byte:
|
|
return json.Unmarshal(v, &c)
|
|
case json.RawMessage:
|
|
return json.Unmarshal(v, &c)
|
|
}
|
|
|
|
return xerrors.Errorf("unexpected type %T", src)
|
|
}
|
|
|
|
//nolint:revive
|
|
func (c ChatACL) RBACACL() map[string][]policy.Action {
|
|
rbacACL := make(map[string][]policy.Action, len(c))
|
|
for id, entry := range c {
|
|
rbacACL[id] = entry.Permissions
|
|
}
|
|
return rbacACL
|
|
}
|
|
|
|
func (c ChatACL) Value() (driver.Value, error) {
|
|
if c == nil {
|
|
return json.Marshal(ChatACL{})
|
|
}
|
|
return json.Marshal(c)
|
|
}
|
|
|
|
type ChatACLEntry struct {
|
|
Permissions []policy.Action `json:"permissions"`
|
|
}
|
|
|
|
type WorkspaceACL map[string]WorkspaceACLEntry
|
|
|
|
func (t *WorkspaceACL) Scan(src interface{}) error {
|
|
switch v := src.(type) {
|
|
case string:
|
|
return json.Unmarshal([]byte(v), &t)
|
|
case []byte:
|
|
return json.Unmarshal(v, &t)
|
|
case json.RawMessage:
|
|
return json.Unmarshal(v, &t)
|
|
}
|
|
|
|
return xerrors.Errorf("unexpected type %T", src)
|
|
}
|
|
|
|
//nolint:revive
|
|
func (w WorkspaceACL) RBACACL() map[string][]policy.Action {
|
|
// Convert WorkspaceACL to a map of string to []policy.Action.
|
|
// This is used for RBAC checks.
|
|
rbacACL := make(map[string][]policy.Action, len(w))
|
|
for id, entry := range w {
|
|
rbacACL[id] = entry.Permissions
|
|
}
|
|
return rbacACL
|
|
}
|
|
|
|
func (t WorkspaceACL) Value() (driver.Value, error) {
|
|
return json.Marshal(t)
|
|
}
|
|
|
|
type WorkspaceACLEntry struct {
|
|
Permissions []policy.Action `json:"permissions"`
|
|
}
|
|
|
|
// WorkspaceACLDisplayInfo supplements workspace ACLs with the actors'
|
|
// display info. Key is string rather than uuid.UUID as this aligns
|
|
// with how RBAC represents actor IDs.
|
|
type WorkspaceACLDisplayInfo map[string]struct {
|
|
Name string `json:"name"`
|
|
AvatarURL string `json:"avatar_url"`
|
|
}
|
|
|
|
// WorkspaceACLDisplayInfo is only used to read from the DB.
|
|
func (w *WorkspaceACLDisplayInfo) Scan(src interface{}) error {
|
|
switch v := src.(type) {
|
|
case string:
|
|
return json.Unmarshal([]byte(v), w)
|
|
case []byte:
|
|
return json.Unmarshal(v, w)
|
|
case json.RawMessage:
|
|
return json.Unmarshal(v, w)
|
|
}
|
|
return xerrors.Errorf("unexpected type %T", src)
|
|
}
|
|
|
|
type ExternalAuthProvider struct {
|
|
ID string `json:"id"`
|
|
Optional bool `json:"optional,omitempty"`
|
|
}
|
|
|
|
type StringMap map[string]string
|
|
|
|
func (m *StringMap) Scan(src interface{}) error {
|
|
if src == nil {
|
|
return nil
|
|
}
|
|
switch src := src.(type) {
|
|
case []byte:
|
|
err := json.Unmarshal(src, m)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
default:
|
|
return xerrors.Errorf("unsupported Scan, storing driver.Value type %T into type %T", src, m)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (m StringMap) Value() (driver.Value, error) {
|
|
return json.Marshal(m)
|
|
}
|
|
|
|
type StringMapOfInt map[string]int64
|
|
|
|
func (m *StringMapOfInt) Scan(src interface{}) error {
|
|
if src == nil {
|
|
return nil
|
|
}
|
|
switch src := src.(type) {
|
|
case []byte:
|
|
err := json.Unmarshal(src, m)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
default:
|
|
return xerrors.Errorf("unsupported Scan, storing driver.Value type %T into type %T", src, m)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (m StringMapOfInt) Value() (driver.Value, error) {
|
|
return json.Marshal(m)
|
|
}
|
|
|
|
type CustomRolePermissions []CustomRolePermission
|
|
|
|
func (s *APIKeyScopes) Scan(src any) error {
|
|
var arr []string
|
|
if err := pq.Array(&arr).Scan(src); err != nil {
|
|
return err
|
|
}
|
|
out := make(APIKeyScopes, len(arr))
|
|
for i, v := range arr {
|
|
out[i] = APIKeyScope(v)
|
|
}
|
|
*s = out
|
|
return nil
|
|
}
|
|
|
|
func (s APIKeyScopes) Value() (driver.Value, error) {
|
|
arr := make([]string, len(s))
|
|
for i, v := range s {
|
|
arr[i] = string(v)
|
|
}
|
|
return pq.Array(arr).Value()
|
|
}
|
|
|
|
func (a *CustomRolePermissions) Scan(src interface{}) error {
|
|
switch v := src.(type) {
|
|
case string:
|
|
return json.Unmarshal([]byte(v), &a)
|
|
case []byte:
|
|
return json.Unmarshal(v, &a)
|
|
}
|
|
return xerrors.Errorf("unexpected type %T", src)
|
|
}
|
|
|
|
func (a CustomRolePermissions) Value() (driver.Value, error) {
|
|
return json.Marshal(a)
|
|
}
|
|
|
|
type CustomRolePermission struct {
|
|
Negate bool `json:"negate"`
|
|
ResourceType string `json:"resource_type"`
|
|
Action policy.Action `json:"action"`
|
|
}
|
|
|
|
func (a CustomRolePermission) String() string {
|
|
str := a.ResourceType + "." + string(a.Action)
|
|
if a.Negate {
|
|
return "-" + str
|
|
}
|
|
return str
|
|
}
|
|
|
|
// NameOrganizationPair is used as a lookup tuple for custom role rows.
|
|
type NameOrganizationPair struct {
|
|
Name string `db:"name" json:"name"`
|
|
// OrganizationID if unset will assume a null column value
|
|
OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"`
|
|
}
|
|
|
|
func (*NameOrganizationPair) Scan(_ interface{}) error {
|
|
return xerrors.Errorf("this should never happen, type 'NameOrganizationPair' should only be used as a parameter")
|
|
}
|
|
|
|
// Value returns the tuple **literal**
|
|
// To get the literal value to return, you can use the expression syntax in a psql
|
|
// shell.
|
|
//
|
|
// SELECT ('customrole'::text,'ece79dac-926e-44ca-9790-2ff7c5eb6e0c'::uuid);
|
|
// To see 'null' option. Using the nil uuid as null to avoid empty string literals for null.
|
|
// SELECT ('customrole',00000000-0000-0000-0000-000000000000);
|
|
//
|
|
// This value is usually used as an array, NameOrganizationPair[]. You can see
|
|
// what that literal is as well, with proper quoting.
|
|
//
|
|
// SELECT ARRAY[('customrole'::text,'ece79dac-926e-44ca-9790-2ff7c5eb6e0c'::uuid)];
|
|
func (a NameOrganizationPair) Value() (driver.Value, error) {
|
|
// The string values must be escaped in case there are special characters, quotes, etc.
|
|
// 'NameOrganizationPair' is a composite value, which has no driver handler
|
|
// in the `pq` package.
|
|
//
|
|
// pq.StringArray formats the single name as `{"<escaped>"}`. Strip
|
|
// the outer braces to get the quoted+escaped form that composite
|
|
// literal syntax accepts unchanged.
|
|
//
|
|
// Ideally `appendArrayQuotedBytes` would be exported, and we could call
|
|
// it directly.
|
|
v, err := (&pq.StringArray{a.Name}).Value()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
s, ok := v.(string)
|
|
if !ok {
|
|
return nil, xerrors.Errorf("unexpected type %T", v)
|
|
}
|
|
|
|
stripCurlyBraces := s[1 : len(s)-1]
|
|
|
|
return fmt.Sprintf("(%s,%s)", stripCurlyBraces, a.OrganizationID.String()), nil
|
|
}
|
|
|
|
// AgentIDNamePair is used as a result tuple for workspace and agent rows.
|
|
type AgentIDNamePair struct {
|
|
ID uuid.UUID `db:"id" json:"id"`
|
|
Name string `db:"name" json:"name"`
|
|
}
|
|
|
|
func (p *AgentIDNamePair) Scan(src interface{}) error {
|
|
var v string
|
|
switch a := src.(type) {
|
|
case []byte:
|
|
v = string(a)
|
|
case string:
|
|
v = a
|
|
default:
|
|
return xerrors.Errorf("unexpected type %T", src)
|
|
}
|
|
parts := strings.Split(strings.Trim(v, "()"), ",")
|
|
if len(parts) != 2 {
|
|
return xerrors.New("invalid format for AgentIDNamePair")
|
|
}
|
|
id, err := uuid.Parse(strings.TrimSpace(parts[0]))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
p.ID, p.Name = id, strings.TrimSpace(parts[1])
|
|
return nil
|
|
}
|
|
|
|
func (p AgentIDNamePair) Value() (driver.Value, error) {
|
|
return fmt.Sprintf(`(%s,%s)`, p.ID.String(), p.Name), nil
|
|
}
|
|
|
|
// UserLinkClaims is the returned IDP claims for a given user link.
|
|
// These claims are fetched at login time. These are the claims that were
|
|
// used for IDP sync.
|
|
type UserLinkClaims struct {
|
|
IDTokenClaims map[string]interface{} `json:"id_token_claims"`
|
|
UserInfoClaims map[string]interface{} `json:"user_info_claims"`
|
|
// MergeClaims are computed in Golang. It is the result of merging
|
|
// the IDTokenClaims and UserInfoClaims. UserInfoClaims take precedence.
|
|
MergedClaims map[string]interface{} `json:"merged_claims"`
|
|
}
|
|
|
|
func (a *UserLinkClaims) Scan(src interface{}) error {
|
|
switch v := src.(type) {
|
|
case string:
|
|
return json.Unmarshal([]byte(v), &a)
|
|
case []byte:
|
|
return json.Unmarshal(v, &a)
|
|
}
|
|
return xerrors.Errorf("unexpected type %T", src)
|
|
}
|
|
|
|
func (a UserLinkClaims) Value() (driver.Value, error) {
|
|
return json.Marshal(a)
|
|
}
|
|
|
|
func ParseIP(ipStr string) pqtype.Inet {
|
|
ip := net.ParseIP(ipStr)
|
|
ipNet := net.IPNet{}
|
|
if ip != nil {
|
|
ipNet = net.IPNet{
|
|
IP: ip,
|
|
Mask: net.CIDRMask(len(ip)*8, len(ip)*8),
|
|
}
|
|
}
|
|
|
|
return pqtype.Inet{
|
|
IPNet: ipNet,
|
|
Valid: ip != nil,
|
|
}
|
|
}
|
|
|
|
// AllowList is a typed wrapper around a list of AllowListTarget entries.
|
|
// It implements sql.Scanner and driver.Valuer so it can be stored in and
|
|
// loaded from a Postgres text[] column that stores each entry in the
|
|
// canonical form "type:id".
|
|
type AllowList []rbac.AllowListElement
|
|
|
|
// Scan implements sql.Scanner. It supports inputs that pq.Array can decode
|
|
// into []string, and then converts each element to an AllowListTarget.
|
|
func (a *AllowList) Scan(src any) error {
|
|
var raw []string
|
|
if err := pq.Array(&raw).Scan(src); err != nil {
|
|
return err
|
|
}
|
|
out := make([]rbac.AllowListElement, len(raw))
|
|
for i, s := range raw {
|
|
e, err := rbac.ParseAllowListEntry(s)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
out[i] = e
|
|
}
|
|
*a = out
|
|
return nil
|
|
}
|
|
|
|
// Value implements driver.Valuer by converting the list to []string using the
|
|
// canonical "type:id" form and delegating to pq.Array for encoding.
|
|
func (a AllowList) Value() (driver.Value, error) {
|
|
raw := make([]string, len(a))
|
|
for i, t := range a {
|
|
raw[i] = t.String()
|
|
}
|
|
return pq.Array(raw).Value()
|
|
}
|