mirror of
https://github.com/andwati/zitadel.git
synced 2026-10-18 05:52:51 +00:00
# Which Problems Are Solved ZITADEL cannot currently be used as an OAuth 2.0 Authorization Server by clients that need to register themselves at runtime: - There is no [OAuth 2.0 Dynamic Client Registration (RFC 7591)](https://datatracker.ietf.org/doc/html/rfc7591) endpoint, and no `registration_endpoint` is advertised in the discovery document. - This blocks [Model Context Protocol (MCP)](https://modelcontextprotocol.io) clients (Claude Desktop, claude.ai, Cursor, the MCP SDKs), whose OAuth 2.1 authorization profile expects an Authorization Server that supports dynamic client registration. They self-register before any user context exists and only then start the authorization code + PKCE flow. # How the Problems Are Solved ## Control plane: instance security settings No feature flag and no runtime config. A `DynamicClientRegistrationSettings` message is nested under the instance `SecuritySettings` in `settings/v2`, next to `embedded_iframe` and `enable_impersonation`: | `enabled` | `allow_unauthenticated` | Behaviour | | --- | --- | --- | | `false` (default) | — | `POST /oauth/v2/register` returns `404`, `registration_endpoint` absent from discovery | | `true` | `false` (default) | Token-gated registration, client homed in the token's organization | | `true` | `true` | Open registration for MCP hosts, client homed in the instance default organization | Both values are carried on `authz.Instance` and loaded with the instance, exactly like `EnableImpersonation`, so the discovery document and the endpoint read them per request without an extra query. `allow_unauthenticated` implies `enabled` in both implementations, so a single setting closes the endpoint. Adding the two columns bumps the projection table to `projections.security_policies3`, following what the impersonation setting did for `security_policies2`. ## Authorization: a dedicated permission, not `project.app.write` - New org-scoped permission **`project.app.register_dynamic`**, granted by default to `ORG_OWNER`, `IAM_OWNER` and `IAM_ORG_MANAGER` — in both `InternalAuthZ` and `SystemAuthZ`, mirroring where `project.app.write` already lives. - New built-in role **`ORG_DYNAMIC_CLIENT_REGISTRAR`** carrying only that permission, so a service user can self-register clients without gaining write access to the organization's existing applications. - Open mode (`allow_unauthenticated = true`) requires no token and no permission: that is the MCP self-register path. - A valid token whose user lacks the permission is answered with **`403 insufficient_scope`** (RFC 6750 §3.1) rather than `401 invalid_token`, so a caller can tell "your token is bad" from "your token may not do this". `verifyAccessToken` also reports invalid tokens as permission denied, so the authorization failure is marked explicitly to keep the two apart. The check runs once, at the endpoint, right after the token is verified and before any state is created, so it also gates the auto-provisioning of the organization's DCR project. The permission itself lives in the command layer next to the others (`Commands.CheckPermissionRegisterDynamicClient`). As the OIDC endpoints are not behind the authorization interceptor, the caller identity is derived from the verified token, the way token exchange does for the impersonating actor. ## Registration itself - **Registered clients are ordinary OIDC applications.** The endpoint reuses the existing application-creation events (`NewApplicationAddedEvent` and `NewOIDCConfigAddedEvent`), so the whole token, authorization and introspection flow keeps working unchanged. They are stored in a dedicated, auto-provisioned project named `ZITADEL DCR` per organization (name lookup), so they do not pollute the `IAMProject` or other projects. - Input is validated with the existing `GetOIDCV1Compliance` rules; `private_key_jwt` and `jwks`/`jwks_uri` are rejected with `invalid_client_metadata`. - **Endpoint wiring without forking the library.** `NewServer` calls `op.RegisterServer` instead of `op.RegisterLegacyServer`, so the new route can be added through the same `op.WithSetRouter` hook as the authorize callback. `op.RegisterLegacyServer` appends a middleware after the caller options, which chi forbids once a route has been registered; `op.NewIssuerInterceptor` reproduces the issuer middleware it would otherwise add. The `/oauth/v2` routing prefix is unchanged. # Additional Changes - **`application_type` inference for custom-scheme redirects** (last commit, isolated). `application_type` is an OpenID Connect member; RFC 7591 does not define it, so clients that only implement RFC 7591 omit it. Native MCP hosts doing so register custom-scheme redirect URIs, which ZITADEL reserves for native applications, and the OIDC default of `web` rejected them with `invalid_redirect_uri`. Inference is limited to custom schemes and to an absent `application_type`, so every request accepted today keeps its current application type and auth method — an `http` loopback redirect still yields a web application. Happy to drop this commit if you would rather keep it out of this PR. - **Docs**: a Dynamic Client Registration integration guide, a `registration_endpoint` section on the OpenID Connect endpoints page, and the new role in the administrators table (plus its description in the Console translations). # Additional Context - Part of #9810. This is **Phase 1 (DCR)**; #12315 adds RFC 7592 and #12316 adds CIMD, both stacked on this branch and rebased once this lands. - Implements the control-plane and authorization model requested in the review of this PR, itself a refinement of the direction in https://github.com/zitadel/zitadel/issues/9810#issuecomment-4891176997. - **Out of scope (deliberately)**: RFC 7592, CIMD, persisting free-form metadata (`logo_uri`, `contacts`, and similar), and `private_key_jwt` / `jwks_uri` (rejected cleanly). No new event types. - Follow-ups explicitly left out, per the review: DCR client lifecycle and richer audit, consent and custom scopes, JWT / `aud` (RFC 8707), and RFC 8414 AS metadata (currently absent from both ZITADEL and `zitadel/oidc`).
679 lines
20 KiB
Go
679 lines
20 KiB
Go
package query
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
_ "embed"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"slices"
|
|
"time"
|
|
|
|
sq "github.com/Masterminds/squirrel"
|
|
"github.com/zitadel/logging"
|
|
"golang.org/x/text/language"
|
|
|
|
"github.com/zitadel/zitadel/internal/api/authz"
|
|
"github.com/zitadel/zitadel/internal/database"
|
|
"github.com/zitadel/zitadel/internal/domain"
|
|
"github.com/zitadel/zitadel/internal/eventstore"
|
|
"github.com/zitadel/zitadel/internal/eventstore/handler/v2"
|
|
target_domain "github.com/zitadel/zitadel/internal/execution/target"
|
|
"github.com/zitadel/zitadel/internal/feature"
|
|
"github.com/zitadel/zitadel/internal/query/projection"
|
|
"github.com/zitadel/zitadel/internal/telemetry/tracing"
|
|
"github.com/zitadel/zitadel/internal/zerrors"
|
|
)
|
|
|
|
const (
|
|
InstancesFilterTableAlias = "f"
|
|
)
|
|
|
|
var (
|
|
instanceTable = table{
|
|
name: projection.InstanceProjectionTable,
|
|
instanceIDCol: projection.InstanceColumnID,
|
|
}
|
|
limitsTable = table{
|
|
name: projection.LimitsProjectionTable,
|
|
instanceIDCol: projection.LimitsColumnInstanceID,
|
|
}
|
|
InstanceColumnID = Column{
|
|
name: projection.InstanceColumnID,
|
|
table: instanceTable,
|
|
}
|
|
InstanceColumnName = Column{
|
|
name: projection.InstanceColumnName,
|
|
table: instanceTable,
|
|
}
|
|
InstanceColumnCreationDate = Column{
|
|
name: projection.InstanceColumnCreationDate,
|
|
table: instanceTable,
|
|
}
|
|
InstanceColumnChangeDate = Column{
|
|
name: projection.InstanceColumnChangeDate,
|
|
table: instanceTable,
|
|
}
|
|
InstanceColumnSequence = Column{
|
|
name: projection.InstanceColumnSequence,
|
|
table: instanceTable,
|
|
}
|
|
InstanceColumnDefaultOrgID = Column{
|
|
name: projection.InstanceColumnDefaultOrgID,
|
|
table: instanceTable,
|
|
}
|
|
InstanceColumnProjectID = Column{
|
|
name: projection.InstanceColumnProjectID,
|
|
table: instanceTable,
|
|
}
|
|
InstanceColumnManagementConsoleID = Column{
|
|
name: projection.InstanceColumnManagementConsoleID,
|
|
table: instanceTable,
|
|
}
|
|
InstanceColumnManagementConsoleAppID = Column{
|
|
name: projection.InstanceColumnManagementConsoleAppID,
|
|
table: instanceTable,
|
|
}
|
|
InstanceColumnDefaultLanguage = Column{
|
|
name: projection.InstanceColumnDefaultLanguage,
|
|
table: instanceTable,
|
|
}
|
|
LimitsColumnInstanceID = Column{
|
|
name: projection.LimitsColumnInstanceID,
|
|
table: limitsTable,
|
|
}
|
|
LimitsColumnAuditLogRetention = Column{
|
|
name: projection.LimitsColumnAuditLogRetention,
|
|
table: limitsTable,
|
|
}
|
|
LimitsColumnBlock = Column{
|
|
name: projection.LimitsColumnBlock,
|
|
table: limitsTable,
|
|
}
|
|
)
|
|
|
|
type Instance struct {
|
|
ID string
|
|
ChangeDate time.Time
|
|
CreationDate time.Time
|
|
Sequence uint64
|
|
Name string
|
|
|
|
DefaultOrgID string
|
|
IAMProjectID string
|
|
ManagementConsoleID string
|
|
ManagementConsoleAppID string
|
|
DefaultLang language.Tag
|
|
Domains []*InstanceDomain
|
|
}
|
|
|
|
type Instances struct {
|
|
SearchResponse
|
|
Instances []*Instance
|
|
}
|
|
|
|
type InstanceSearchQueries struct {
|
|
SearchRequest
|
|
Queries []SearchQuery
|
|
}
|
|
|
|
func NewInstanceIDsListSearchQuery(ids ...string) (SearchQuery, error) {
|
|
list := make([]interface{}, len(ids))
|
|
for i, value := range ids {
|
|
list[i] = value
|
|
}
|
|
return NewListQuery(InstanceColumnID, list, ListIn)
|
|
}
|
|
|
|
func NewInstanceDomainsListSearchQuery(domains ...string) (SearchQuery, error) {
|
|
list := make([]interface{}, len(domains))
|
|
for i, value := range domains {
|
|
list[i] = value
|
|
}
|
|
|
|
return NewListQuery(InstanceDomainDomainCol, list, ListIn)
|
|
}
|
|
|
|
func (q *InstanceSearchQueries) toQuery(query sq.SelectBuilder) sq.SelectBuilder {
|
|
query = q.SearchRequest.toQuery(query)
|
|
for _, q := range q.Queries {
|
|
query = q.toQuery(query)
|
|
}
|
|
return query
|
|
}
|
|
|
|
func (q *Queries) ActiveInstances() []string {
|
|
return q.caches.activeInstances.Keys()
|
|
}
|
|
|
|
func (q *Queries) SearchInstances(ctx context.Context, queries *InstanceSearchQueries) (instances *Instances, err error) {
|
|
ctx, span := tracing.NewSpan(ctx)
|
|
defer func() { span.EndWithError(err) }()
|
|
|
|
filter, query, scan := prepareInstancesQuery(queries.SortingColumn, queries.Asc)
|
|
stmt, args, err := query(queries.toQuery(filter)).ToSql()
|
|
if err != nil {
|
|
return nil, zerrors.ThrowInvalidArgument(err, "QUERY-M9fow", "Errors.Query.SQLStatement")
|
|
}
|
|
|
|
err = q.client.QueryContext(ctx, func(rows *sql.Rows) error {
|
|
instances, err = scan(rows)
|
|
return err
|
|
}, stmt, args...)
|
|
if err != nil {
|
|
return nil, zerrors.ThrowInternal(err, "QUERY-3j98f", "Errors.Internal")
|
|
}
|
|
return instances, err
|
|
}
|
|
|
|
func (q *Queries) Instance(ctx context.Context, shouldTriggerBulk bool) (instance *Instance, err error) {
|
|
ctx, span := tracing.NewSpan(ctx)
|
|
defer func() { span.EndWithError(err) }()
|
|
|
|
if shouldTriggerBulk {
|
|
_, traceSpan := tracing.NewNamedSpan(ctx, "TriggerInstanceProjection")
|
|
ctx, err = projection.InstanceProjection.Trigger(ctx, handler.WithAwaitRunning())
|
|
logging.OnError(err).Debug("trigger failed")
|
|
traceSpan.EndWithError(err)
|
|
}
|
|
|
|
stmt, scan := prepareInstanceDomainQuery()
|
|
query, args, err := stmt.Where(sq.Eq{
|
|
InstanceColumnID.identifier(): authz.GetInstance(ctx).InstanceID(),
|
|
}).ToSql()
|
|
if err != nil {
|
|
return nil, zerrors.ThrowInternal(err, "QUERY-d9ngs", "Errors.Query.SQLStatement")
|
|
}
|
|
|
|
err = q.client.QueryContext(ctx, func(rows *sql.Rows) error {
|
|
instance, err = scan(rows)
|
|
return err
|
|
}, query, args...)
|
|
return instance, err
|
|
}
|
|
|
|
var (
|
|
//go:embed instance_by_domain.sql
|
|
instanceByDomainQuery string
|
|
|
|
//go:embed instance_by_id.sql
|
|
instanceByIDQuery string
|
|
)
|
|
|
|
func (q *Queries) InstanceByHost(ctx context.Context, instanceDomain, publicDomain string) (_ authz.Instance, err error) {
|
|
var instance *authzInstance
|
|
ctx, span := tracing.NewSpan(ctx)
|
|
defer func() {
|
|
if err != nil {
|
|
err = fmt.Errorf("unable to get instance by domain: instanceDomain %s, publicHostname %s: %w", instanceDomain, publicDomain, err)
|
|
} else {
|
|
q.caches.activeInstances.Add(instance.ID, true)
|
|
}
|
|
span.EndWithError(err)
|
|
}()
|
|
|
|
instance, ok := q.caches.instance.Get(ctx, instanceIndexByHost, instanceDomain)
|
|
if ok {
|
|
return instance, instance.checkDomain(instanceDomain, publicDomain)
|
|
}
|
|
instance, scan := scanAuthzInstance()
|
|
if err = q.client.QueryRowContext(ctx, scan, instanceByDomainQuery, instanceDomain); err != nil {
|
|
return nil, err
|
|
}
|
|
q.caches.instance.Set(ctx, instance)
|
|
|
|
return instance, instance.checkDomain(instanceDomain, publicDomain)
|
|
}
|
|
|
|
func (q *Queries) InstanceByID(ctx context.Context, id string) (_ authz.Instance, err error) {
|
|
ctx, span := tracing.NewSpan(ctx)
|
|
defer func() { span.EndWithError(err) }()
|
|
defer func() {
|
|
if err != nil {
|
|
return
|
|
}
|
|
q.caches.activeInstances.Add(id, true)
|
|
}()
|
|
|
|
instance, ok := q.caches.instance.Get(ctx, instanceIndexByID, id)
|
|
if ok {
|
|
return instance, nil
|
|
}
|
|
|
|
instance, scan := scanAuthzInstance()
|
|
err = q.client.QueryRowContext(ctx, scan, instanceByIDQuery, id)
|
|
logging.OnError(err).WithField("instance_id", id).Warn("instance by ID")
|
|
|
|
if err == nil {
|
|
q.caches.instance.Set(ctx, instance)
|
|
}
|
|
return instance, err
|
|
}
|
|
|
|
func (q *Queries) GetDefaultLanguage(ctx context.Context) language.Tag {
|
|
instance, err := q.Instance(ctx, false)
|
|
if err != nil {
|
|
return language.Und
|
|
}
|
|
return instance.DefaultLang
|
|
}
|
|
|
|
func prepareInstancesQuery(sortBy Column, isAscedingSort bool) (sq.SelectBuilder, func(sq.SelectBuilder) sq.SelectBuilder, func(*sql.Rows) (*Instances, error)) {
|
|
instanceFilterTable := instanceTable.setAlias(InstancesFilterTableAlias)
|
|
instanceFilterIDColumn := InstanceColumnID.setTable(instanceFilterTable)
|
|
instanceFilterCountColumn := InstancesFilterTableAlias + ".count"
|
|
|
|
selector := sq.Select(InstanceColumnID.identifier(), countColumn.identifier())
|
|
if !sortBy.isZero() {
|
|
selector = sq.Select(InstanceColumnID.identifier(), countColumn.identifier(), sortBy.identifier())
|
|
}
|
|
|
|
return selector.Distinct().From(instanceTable.identifier()).
|
|
LeftJoin(join(InstanceDomainInstanceIDCol, InstanceColumnID)),
|
|
func(builder sq.SelectBuilder) sq.SelectBuilder {
|
|
outerQuery := sq.Select(
|
|
instanceFilterCountColumn,
|
|
instanceFilterIDColumn.identifier(),
|
|
InstanceColumnCreationDate.identifier(),
|
|
InstanceColumnChangeDate.identifier(),
|
|
InstanceColumnSequence.identifier(),
|
|
InstanceColumnName.identifier(),
|
|
InstanceColumnDefaultOrgID.identifier(),
|
|
InstanceColumnProjectID.identifier(),
|
|
InstanceColumnManagementConsoleID.identifier(),
|
|
InstanceColumnManagementConsoleAppID.identifier(),
|
|
InstanceColumnDefaultLanguage.identifier(),
|
|
InstanceDomainDomainCol.identifier(),
|
|
InstanceDomainIsPrimaryCol.identifier(),
|
|
InstanceDomainIsGeneratedCol.identifier(),
|
|
InstanceDomainCreationDateCol.identifier(),
|
|
InstanceDomainChangeDateCol.identifier(),
|
|
InstanceDomainSequenceCol.identifier(),
|
|
).FromSelect(builder, InstancesFilterTableAlias).
|
|
LeftJoin(join(InstanceColumnID, instanceFilterIDColumn)).
|
|
LeftJoin(join(InstanceDomainInstanceIDCol, instanceFilterIDColumn)).
|
|
PlaceholderFormat(sq.Dollar)
|
|
|
|
if !sortBy.isZero() {
|
|
sorting := sortBy.identifier()
|
|
if !isAscedingSort {
|
|
sorting += " DESC"
|
|
}
|
|
return outerQuery.OrderBy(sorting)
|
|
}
|
|
|
|
return outerQuery
|
|
},
|
|
func(rows *sql.Rows) (*Instances, error) {
|
|
instances := make([]*Instance, 0)
|
|
var lastInstance *Instance
|
|
var count uint64
|
|
for rows.Next() {
|
|
instance := new(Instance)
|
|
lang := ""
|
|
var (
|
|
domain sql.NullString
|
|
isPrimary sql.NullBool
|
|
isGenerated sql.NullBool
|
|
changeDate sql.NullTime
|
|
creationDate sql.NullTime
|
|
sequence sql.NullInt64
|
|
)
|
|
err := rows.Scan(
|
|
&count,
|
|
&instance.ID,
|
|
&instance.CreationDate,
|
|
&instance.ChangeDate,
|
|
&instance.Sequence,
|
|
&instance.Name,
|
|
&instance.DefaultOrgID,
|
|
&instance.IAMProjectID,
|
|
&instance.ManagementConsoleID,
|
|
&instance.ManagementConsoleAppID,
|
|
&lang,
|
|
&domain,
|
|
&isPrimary,
|
|
&isGenerated,
|
|
&changeDate,
|
|
&creationDate,
|
|
&sequence,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if instance.ID == "" || !domain.Valid {
|
|
continue
|
|
}
|
|
instance.DefaultLang = language.Make(lang)
|
|
instanceDomain := &InstanceDomain{
|
|
CreationDate: creationDate.Time,
|
|
ChangeDate: changeDate.Time,
|
|
Sequence: uint64(sequence.Int64),
|
|
Domain: domain.String,
|
|
IsPrimary: isPrimary.Bool,
|
|
IsGenerated: isGenerated.Bool,
|
|
InstanceID: instance.ID,
|
|
}
|
|
if lastInstance != nil && instance.ID == lastInstance.ID {
|
|
lastInstance.Domains = append(lastInstance.Domains, instanceDomain)
|
|
continue
|
|
}
|
|
lastInstance = instance
|
|
instance.Domains = append(instance.Domains, instanceDomain)
|
|
instances = append(instances, instance)
|
|
}
|
|
|
|
if err := rows.Close(); err != nil {
|
|
return nil, zerrors.ThrowInternal(err, "QUERY-8nlWW", "Errors.Query.CloseRows")
|
|
}
|
|
|
|
return &Instances{
|
|
Instances: instances,
|
|
SearchResponse: SearchResponse{
|
|
Count: count,
|
|
},
|
|
}, nil
|
|
}
|
|
}
|
|
|
|
func prepareInstanceDomainQuery() (sq.SelectBuilder, func(*sql.Rows) (*Instance, error)) {
|
|
return sq.Select(
|
|
InstanceColumnID.identifier(),
|
|
InstanceColumnCreationDate.identifier(),
|
|
InstanceColumnChangeDate.identifier(),
|
|
InstanceColumnSequence.identifier(),
|
|
InstanceColumnName.identifier(),
|
|
InstanceColumnDefaultOrgID.identifier(),
|
|
InstanceColumnProjectID.identifier(),
|
|
InstanceColumnManagementConsoleID.identifier(),
|
|
InstanceColumnManagementConsoleAppID.identifier(),
|
|
InstanceColumnDefaultLanguage.identifier(),
|
|
InstanceDomainDomainCol.identifier(),
|
|
InstanceDomainIsPrimaryCol.identifier(),
|
|
InstanceDomainIsGeneratedCol.identifier(),
|
|
InstanceDomainCreationDateCol.identifier(),
|
|
InstanceDomainChangeDateCol.identifier(),
|
|
InstanceDomainSequenceCol.identifier(),
|
|
).
|
|
From(instanceTable.identifier()).
|
|
LeftJoin(join(InstanceDomainInstanceIDCol, InstanceColumnID)).
|
|
PlaceholderFormat(sq.Dollar),
|
|
func(rows *sql.Rows) (*Instance, error) {
|
|
instance := &Instance{
|
|
Domains: make([]*InstanceDomain, 0),
|
|
}
|
|
lang := ""
|
|
for rows.Next() {
|
|
var (
|
|
domain sql.NullString
|
|
isPrimary sql.NullBool
|
|
isGenerated sql.NullBool
|
|
changeDate sql.NullTime
|
|
creationDate sql.NullTime
|
|
sequence sql.NullInt64
|
|
)
|
|
err := rows.Scan(
|
|
&instance.ID,
|
|
&instance.CreationDate,
|
|
&instance.ChangeDate,
|
|
&instance.Sequence,
|
|
&instance.Name,
|
|
&instance.DefaultOrgID,
|
|
&instance.IAMProjectID,
|
|
&instance.ManagementConsoleID,
|
|
&instance.ManagementConsoleAppID,
|
|
&lang,
|
|
&domain,
|
|
&isPrimary,
|
|
&isGenerated,
|
|
&changeDate,
|
|
&creationDate,
|
|
&sequence,
|
|
)
|
|
if err != nil {
|
|
return nil, zerrors.ThrowInternal(err, "QUERY-d9nw", "Errors.Internal")
|
|
}
|
|
if !domain.Valid {
|
|
continue
|
|
}
|
|
instance.Domains = append(instance.Domains, &InstanceDomain{
|
|
CreationDate: creationDate.Time,
|
|
ChangeDate: changeDate.Time,
|
|
Sequence: uint64(sequence.Int64),
|
|
Domain: domain.String,
|
|
IsPrimary: isPrimary.Bool,
|
|
IsGenerated: isGenerated.Bool,
|
|
InstanceID: instance.ID,
|
|
})
|
|
}
|
|
if instance.ID == "" {
|
|
return nil, zerrors.ThrowNotFound(nil, "QUERY-n0wng", "Errors.Instance.NotFound")
|
|
}
|
|
instance.DefaultLang = language.Make(lang)
|
|
if err := rows.Close(); err != nil {
|
|
return nil, zerrors.ThrowInternal(err, "QUERY-Dfbe2", "Errors.Query.CloseRows")
|
|
}
|
|
return instance, nil
|
|
}
|
|
}
|
|
|
|
type authzInstance struct {
|
|
ID string `json:"id,omitempty"`
|
|
IAMProjectID string `json:"iam_project_id,omitempty"`
|
|
ManagementConsoleID string `json:"console_id,omitempty"`
|
|
ManagementConsoleAppID string `json:"console_app_id,omitempty"`
|
|
DefaultLang language.Tag `json:"default_lang,omitempty"`
|
|
DefaultOrgID string `json:"default_org_id,omitempty"`
|
|
CSP csp `json:"csp,omitempty"`
|
|
Impersonation bool `json:"impersonation,omitempty"`
|
|
DCR dcr `json:"dcr,omitempty"`
|
|
IsBlocked *bool `json:"is_blocked,omitempty"`
|
|
LogRetention *time.Duration `json:"log_retention,omitempty"`
|
|
Feature feature.Features `json:"feature,omitempty"`
|
|
ExternalDomains database.TextArray[string] `json:"external_domains,omitempty"`
|
|
TrustedDomains database.TextArray[string] `json:"trusted_domains,omitempty"`
|
|
ExecutionTargets target_domain.Router `json:"execution_targets,omitzero"`
|
|
AllowedLangs []language.Tag `json:"allowed_langs,omitempty"`
|
|
}
|
|
|
|
type csp struct {
|
|
EnableIframeEmbedding bool `json:"enable_iframe_embedding,omitempty"`
|
|
AllowedOrigins database.TextArray[string] `json:"allowed_origins,omitempty"`
|
|
}
|
|
|
|
// dcr holds the instance's OAuth 2.0 Dynamic Client Registration (RFC 7591) settings.
|
|
type dcr struct {
|
|
Enabled bool `json:"enabled,omitempty"`
|
|
AllowUnauthenticated bool `json:"allow_unauthenticated,omitempty"`
|
|
}
|
|
|
|
func (i *authzInstance) InstanceID() string {
|
|
return i.ID
|
|
}
|
|
|
|
func (i *authzInstance) ProjectID() string {
|
|
return i.IAMProjectID
|
|
}
|
|
|
|
func (i *authzInstance) ManagementConsoleClientID() string {
|
|
return i.ManagementConsoleID
|
|
}
|
|
|
|
func (i *authzInstance) ManagementConsoleApplicationID() string {
|
|
return i.ManagementConsoleAppID
|
|
}
|
|
|
|
func (i *authzInstance) DefaultLanguage() language.Tag {
|
|
return i.DefaultLang
|
|
}
|
|
|
|
func (i *authzInstance) AllowedLanguages() []language.Tag {
|
|
return i.AllowedLangs
|
|
}
|
|
|
|
func (i *authzInstance) DefaultOrganisationID() string {
|
|
return i.DefaultOrgID
|
|
}
|
|
|
|
func (i *authzInstance) SecurityPolicyAllowedOrigins() []string {
|
|
if !i.CSP.EnableIframeEmbedding {
|
|
return nil
|
|
}
|
|
return i.CSP.AllowedOrigins
|
|
}
|
|
|
|
func (i *authzInstance) EnableImpersonation() bool {
|
|
return i.Impersonation
|
|
}
|
|
|
|
func (i *authzInstance) EnableDynamicClientRegistration() bool {
|
|
return i.DCR.Enabled
|
|
}
|
|
|
|
func (i *authzInstance) AllowUnauthenticatedDynamicClientRegistration() bool {
|
|
return i.DCR.Enabled && i.DCR.AllowUnauthenticated
|
|
}
|
|
|
|
func (i *authzInstance) Block() *bool {
|
|
return i.IsBlocked
|
|
}
|
|
|
|
func (i *authzInstance) AuditLogRetention() *time.Duration {
|
|
return i.LogRetention
|
|
}
|
|
|
|
func (i *authzInstance) Features() feature.Features {
|
|
return i.Feature
|
|
}
|
|
|
|
func (i *authzInstance) ExecutionRouter() target_domain.Router {
|
|
return i.ExecutionTargets
|
|
}
|
|
|
|
var errPublicDomain = "public domain %q not trusted"
|
|
|
|
func (i *authzInstance) checkDomain(instanceDomain, publicDomain string) error {
|
|
// in case public domain is empty, or the same as the instance domain, we do not need to check it
|
|
if publicDomain == "" || instanceDomain == publicDomain {
|
|
return nil
|
|
}
|
|
if !slices.Contains(i.TrustedDomains, publicDomain) {
|
|
return zerrors.ThrowNotFound(fmt.Errorf(errPublicDomain, publicDomain), "QUERY-IuGh1", "Errors.Instance.NotFound")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Keys implements [cache.Entry]
|
|
func (i *authzInstance) Keys(index instanceIndex) []string {
|
|
switch index {
|
|
case instanceIndexByID:
|
|
return []string{i.ID}
|
|
case instanceIndexByHost:
|
|
return i.ExternalDomains
|
|
case instanceIndexUnspecified:
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func scanAuthzInstance() (*authzInstance, func(row *sql.Row) error) {
|
|
instance := &authzInstance{}
|
|
return instance, func(row *sql.Row) error {
|
|
var (
|
|
lang string
|
|
enableIframeEmbedding sql.NullBool
|
|
enableImpersonation sql.NullBool
|
|
enableDCR sql.NullBool
|
|
allowUnauthDCR sql.NullBool
|
|
auditLogRetention database.NullDuration
|
|
block sql.NullBool
|
|
features []byte
|
|
executionTargetsBytes []byte
|
|
allowedLanguages database.TextArray[string]
|
|
)
|
|
err := row.Scan(
|
|
&instance.ID,
|
|
&instance.DefaultOrgID,
|
|
&instance.IAMProjectID,
|
|
&instance.ManagementConsoleID,
|
|
&instance.ManagementConsoleAppID,
|
|
&lang,
|
|
&enableIframeEmbedding,
|
|
&instance.CSP.AllowedOrigins,
|
|
&enableImpersonation,
|
|
&enableDCR,
|
|
&allowUnauthDCR,
|
|
&auditLogRetention,
|
|
&block,
|
|
&features,
|
|
&instance.ExternalDomains,
|
|
&instance.TrustedDomains,
|
|
&executionTargetsBytes,
|
|
&allowedLanguages,
|
|
)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return zerrors.ThrowNotFound(nil, "QUERY-1kIjX", "Errors.Instance.NotFound")
|
|
}
|
|
if err != nil {
|
|
return zerrors.ThrowInternal(err, "QUERY-d3fas", "Errors.Internal")
|
|
}
|
|
instance.DefaultLang = language.Make(lang)
|
|
if auditLogRetention.Valid {
|
|
instance.LogRetention = &auditLogRetention.Duration
|
|
}
|
|
if block.Valid {
|
|
instance.IsBlocked = &block.Bool
|
|
}
|
|
instance.CSP.EnableIframeEmbedding = enableIframeEmbedding.Bool
|
|
instance.Impersonation = enableImpersonation.Bool
|
|
instance.DCR.Enabled = enableDCR.Bool
|
|
instance.DCR.AllowUnauthenticated = allowUnauthDCR.Bool
|
|
if len(features) > 0 {
|
|
if err = json.Unmarshal(features, &instance.Feature); err != nil {
|
|
return zerrors.ThrowInternal(err, "QUERY-Po8ki", "Errors.Internal")
|
|
}
|
|
}
|
|
if len(executionTargetsBytes) > 0 {
|
|
var targets []target_domain.Target
|
|
if err := json.Unmarshal(executionTargetsBytes, &targets); err != nil {
|
|
return zerrors.ThrowInternal(err, "QUERY-aeKa2", "Errors.Internal")
|
|
}
|
|
instance.ExecutionTargets = target_domain.NewRouter(targets)
|
|
}
|
|
instance.AllowedLangs = domain.StringsToLanguages(allowedLanguages)
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func (c *Caches) registerInstanceInvalidation() {
|
|
invalidate := cacheInvalidationFunc(c.instance, instanceIndexByID, getAggregateID)
|
|
projection.InstanceProjection.RegisterCacheInvalidation(invalidate)
|
|
projection.InstanceDomainProjection.RegisterCacheInvalidation(invalidate)
|
|
projection.InstanceFeatureProjection.RegisterCacheInvalidation(invalidate)
|
|
projection.InstanceTrustedDomainProjection.RegisterCacheInvalidation(invalidate)
|
|
projection.SecurityPolicyProjection.RegisterCacheInvalidation(invalidate)
|
|
|
|
// These projections have their own aggregate ID, invalidate using resource owner.
|
|
invalidate = cacheInvalidationFunc(c.instance, instanceIndexByID, getResourceOwner)
|
|
projection.LimitsProjection.RegisterCacheInvalidation(invalidate)
|
|
projection.RestrictionsProjection.RegisterCacheInvalidation(invalidate)
|
|
projection.ExecutionProjection.RegisterCacheInvalidation(invalidate)
|
|
projection.TargetProjection.RegisterCacheInvalidation(invalidate)
|
|
|
|
// System feature update should invalidate all instances, so Truncate the cache.
|
|
projection.SystemFeatureProjection.RegisterCacheInvalidation(func(ctx context.Context, _ []*eventstore.Aggregate) {
|
|
err := c.instance.Truncate(ctx)
|
|
logging.OnError(err).Warn("cache truncate failed")
|
|
})
|
|
}
|
|
|
|
type instanceIndex int
|
|
|
|
//go:generate enumer -type instanceIndex -linecomment
|
|
const (
|
|
// Empty line comment ensures empty string for unspecified value
|
|
instanceIndexUnspecified instanceIndex = iota //
|
|
instanceIndexByID
|
|
instanceIndexByHost
|
|
)
|