Files
Tim MöhlmannandGitHub eb22b58756 feat(telemetry): improved instrumentation for observability (#11159)
# Which Problems Are Solved

Zitadel did not provide easy correlation between errors, logs, traces
and metrics. The configuration for those instrumentations was also not
consistent, with some supporting different exporters then others.
Implementation and parsing of config was also spaghettified over
multiple packages, with awkward parsing and inconsistent naming of
options.

# How the Problems Are Solved

All telemetry is now merged under the name "instrumentation". Why?
1. We thought it was a good idea in the past to call the milestone
exporter `Telemtry` in the runtime config. Calling this `TelemetryV2`
looks weird.
2. Not everything is a meter and not everything is sent (tele...). 
3. It's also
[defined](https://opentelemetry.io/docs/concepts/instrumentation/) as
such by the OTEL documentation.

## New features

- Adds structured, context based logging with trace-ID awareness
- Static log fields are added to the context, such as service and
request path
- Static log fields are injected in each logline emitted by the
application
- Structured logs can also be send to an otel exporter
- Structured logs can be printed to StdErr in text and JSON format
- Error sinks make sure every error is logged at the correct level:
- Warnings for client side errors (HTTP 400 range, Invalid request etc)
  - Error for server side errors (Internal server errors)
- Metrics can now also be send to a OTEL collector. (previously they
could only be scraped from `/debug/metrics` with prometheus)

## Exporters

This change adds all the exporters supported by OTEL upstream and some
google specific exporters for our cloud deployment.

- StdOut / StdErr: all instrumentations
- OTEL gRPC / HTTP: all instrumentations
- Google: all instrumentations except logging
- Prometheus (pull-based): only metrics

The exception is profiling, which only supports the google exporting due
to lack of support by OTEL upstream.

## Configuration and structure 

- All instrumentation is moved into the new `backend/v3/instrumentation`
package. It reuses configuration types, so both code and runtime
configuration are easier to understand.
- The `internal/telemetry` packages are removed.
- Instrumentation is started with a single function and a proper
shutdown function is now provided.
- Legacy configuration is still parsed from the runtime config, as long
as the new configuration is disabled. This allows backporting this
feature to v4 without breaking existing configurations.

# Additional Changes

- Devcontainer: set `$PATH` variable so installed go binaries can be run
individually, without NX.
- NX: install GCI tool to fix imports

# Additional Context

- Closes https://github.com/zitadel/zitadel/issues/8408
- Closes https://github.com/zitadel/zitadel/issues/6664
- Backport to v4
2026-01-12 05:51:39 +00:00

284 lines
9.6 KiB
Go

package oidc
import (
"context"
"encoding/json"
"slices"
"strings"
"time"
"github.com/go-jose/go-jose/v4"
"github.com/zitadel/oidc/v3/pkg/oidc"
"github.com/zitadel/oidc/v3/pkg/op"
"github.com/zitadel/zitadel/internal/actions"
"github.com/zitadel/zitadel/internal/api/authz"
"github.com/zitadel/zitadel/internal/domain"
"github.com/zitadel/zitadel/internal/query"
"github.com/zitadel/zitadel/internal/telemetry/tracing"
"github.com/zitadel/zitadel/internal/zerrors"
)
const (
ClaimPrefix = "urn:zitadel:iam"
ScopeProjectRolePrefix = "urn:zitadel:iam:org:project:role:"
ScopeProjectsRoles = "urn:zitadel:iam:org:projects:roles"
ClaimProjectRoles = "urn:zitadel:iam:org:project:roles"
ClaimProjectRolesFormat = "urn:zitadel:iam:org:project:%s:roles"
ScopeUserMetaData = "urn:zitadel:iam:user:metadata"
ClaimUserMetaData = ScopeUserMetaData
ScopeResourceOwner = "urn:zitadel:iam:user:resourceowner"
ClaimResourceOwnerID = ScopeResourceOwner + ":id"
ClaimResourceOwnerName = ScopeResourceOwner + ":name"
ClaimResourceOwnerPrimaryDomain = ScopeResourceOwner + ":primary_domain"
ClaimActionLogFormat = "urn:zitadel:iam:action:%s:log"
ScopeUserGroups = "groups"
ClaimUserGroups = ScopeUserGroups
ScopeCustomUserGroups = "urn:zitadel:iam:user:groups"
ClaimCustomUserGroups = ScopeCustomUserGroups
)
// GetClientByClientID implements the op.Storage interface to retrieve an OIDC client by its ID.
//
// TODO: Still used for Auth request creation for v1 login.
func (o *OPStorage) GetClientByClientID(ctx context.Context, id string) (_ op.Client, err error) {
ctx, span := tracing.NewSpan(ctx)
defer func() {
err = oidcError(ctx, err)
span.EndWithError(err)
}()
client, err := o.query.ActiveOIDCClientByID(ctx, id, false)
if err != nil {
return nil, err
}
return ClientFromBusiness(client, o.defaultLoginURL, o.defaultLoginURLV2), nil
}
func (o *OPStorage) GetKeyByIDAndClientID(context.Context, string, string) (*jose.JSONWebKey, error) {
panic(o.panicErr("GetKeyByIDAndClientID"))
}
func (o *OPStorage) ValidateJWTProfileScopes(context.Context, string, []string) ([]string, error) {
panic(o.panicErr("ValidateJWTProfileScopes"))
}
func (o *OPStorage) AuthorizeClientIDSecret(context.Context, string, string) error {
panic(o.panicErr("AuthorizeClientIDSecret"))
}
func (o *OPStorage) SetUserinfoFromToken(context.Context, *oidc.UserInfo, string, string, string) error {
panic(o.panicErr("SetUserinfoFromToken"))
}
func (o *OPStorage) SetUserinfoFromScopes(context.Context, *oidc.UserInfo, string, string, []string) error {
panic(o.panicErr("SetUserinfoFromScopes"))
}
func (o *OPStorage) SetUserinfoFromRequest(context.Context, *oidc.UserInfo, op.IDTokenRequest, []string) error {
panic(o.panicErr("SetUserinfoFromRequest"))
}
func (o *OPStorage) SetIntrospectionFromToken(context.Context, *oidc.IntrospectionResponse, string, string, string) error {
panic(o.panicErr("SetIntrospectionFromToken"))
}
func (o *OPStorage) ClientCredentialsTokenRequest(context.Context, string, []string) (op.TokenRequest, error) {
panic(o.panicErr("ClientCredentialsTokenRequest"))
}
func (o *OPStorage) ClientCredentials(context.Context, string, string) (op.Client, error) {
panic(o.panicErr("ClientCredentials"))
}
func (o *OPStorage) GetPrivateClaimsFromScopes(context.Context, string, string, []string) (map[string]interface{}, error) {
panic(o.panicErr("GetPrivateClaimsFromScopes"))
}
func checkGrantedRoles(roles *projectsRoles, grant query.UserGrant, requestedRole string, isRequested bool) {
for _, grantedRole := range grant.Roles {
if requestedRole == grantedRole {
roles.Add(grant.ProjectID, grantedRole, grant.ResourceOwner, grant.OrgPrimaryDomain, isRequested)
}
}
}
// projectsRoles contains all projects with all their roles for a user
type projectsRoles struct {
// key is projectID
projects map[string]projectRoles
requestProjectID string
}
func newProjectRoles(projectID string, grants []query.UserGrant, requestedRoles []string) *projectsRoles {
roles := new(projectsRoles)
// if specific roles where requested, check if they are granted and append them in the roles list
if len(requestedRoles) > 0 {
for _, requestedRole := range requestedRoles {
for _, grant := range grants {
checkGrantedRoles(roles, grant, requestedRole, grant.ProjectID == projectID)
}
}
return roles
}
// no specific roles were requested, so convert any grants into roles
for _, grant := range grants {
for _, role := range grant.Roles {
roles.Add(grant.ProjectID, role, grant.ResourceOwner, grant.OrgPrimaryDomain, grant.ProjectID == projectID)
}
}
return roles
}
func (p *projectsRoles) Add(projectID, roleKey, orgID, domain string, isRequested bool) {
if p.projects == nil {
p.projects = make(map[string]projectRoles, 1)
}
if p.projects[projectID] == nil {
p.projects[projectID] = make(projectRoles)
}
if isRequested {
p.requestProjectID = projectID
}
p.projects[projectID].Add(roleKey, orgID, domain)
}
// projectRoles contains the roles of a project of multiple organisations
//
// key of the first map is the role key,
// key of the second map is the org id, value the org domain
type projectRoles map[string]map[string]string
func (p projectRoles) Add(roleKey, orgID, domain string) {
if p[roleKey] == nil {
p[roleKey] = make(map[string]string, 1)
}
p[roleKey][orgID] = domain
}
func getGender(gender domain.Gender) oidc.Gender {
switch gender {
case domain.GenderFemale:
return "female"
case domain.GenderMale:
return "male"
case domain.GenderDiverse:
return "diverse"
}
return ""
}
func userinfoClaims(userInfo *oidc.UserInfo) func(c *actions.FieldConfig) interface{} {
return func(c *actions.FieldConfig) interface{} {
marshalled, err := json.Marshal(userInfo)
if err != nil {
panic(err)
}
claims := make(map[string]interface{}, 10)
if err = json.Unmarshal(marshalled, &claims); err != nil {
panic(err)
}
return c.Runtime.ToValue(claims)
}
}
func (s *Server) VerifyClient(ctx context.Context, r *op.Request[op.ClientCredentials]) (_ op.Client, err error) {
ctx, span := tracing.NewSpan(ctx)
defer func() {
err = oidcError(ctx, err)
span.EndWithError(err)
}()
if oidc.GrantType(r.Form.Get("grant_type")) == oidc.GrantTypeClientCredentials {
return s.clientCredentialsAuth(ctx, r.Data.ClientID, r.Data.ClientSecret)
}
clientID, assertion, err := clientIDFromCredentials(ctx, r.Data)
if err != nil {
return nil, err
}
client, err := s.query.ActiveOIDCClientByID(ctx, clientID, assertion)
if zerrors.IsNotFound(err) {
return nil, oidc.ErrInvalidClient().WithParent(err).WithReturnParentToClient(authz.GetFeatures(ctx).DebugOIDCParentError).WithDescription("no active client not found")
}
if err != nil {
return nil, err // defaults to server error
}
if client.Settings == nil {
client.Settings = &query.OIDCSettings{
AccessTokenLifetime: s.defaultAccessTokenLifetime,
IdTokenLifetime: s.defaultIdTokenLifetime,
}
}
switch client.AuthMethodType {
case domain.OIDCAuthMethodTypeBasic, domain.OIDCAuthMethodTypePost:
err = s.verifyClientSecret(ctx, client, r.Data.ClientSecret)
case domain.OIDCAuthMethodTypePrivateKeyJWT:
err = s.verifyClientAssertion(ctx, client, r.Data.ClientAssertion)
case domain.OIDCAuthMethodTypeNone:
}
if err != nil {
return nil, err
}
return ClientFromBusiness(client, s.defaultLoginURL, s.defaultLoginURLV2), nil
}
func (s *Server) verifyClientAssertion(ctx context.Context, client *query.OIDCClient, assertion string) (err error) {
ctx, span := tracing.NewSpan(ctx)
defer func() { span.EndWithError(err) }()
if assertion == "" {
return oidc.ErrInvalidClient().WithDescription("empty client assertion")
}
verifier := op.NewJWTProfileVerifierKeySet(keySetMap(client.PublicKeys), op.IssuerFromContext(ctx), time.Hour, client.ClockSkew)
if _, err := op.VerifyJWTAssertion(ctx, assertion, verifier); err != nil {
return oidc.ErrInvalidClient().WithParent(err).WithReturnParentToClient(authz.GetFeatures(ctx).DebugOIDCParentError).WithDescription("invalid assertion")
}
return nil
}
func (s *Server) verifyClientSecret(ctx context.Context, client *query.OIDCClient, secret string) (err error) {
_, span := tracing.NewSpan(ctx)
defer func() { span.EndWithError(err) }()
if secret == "" {
return oidc.ErrInvalidClient().WithDescription("empty client secret")
}
ctx, spanPasswordComparison := tracing.NewNamedSpan(ctx, "passwap.Verify")
updated, err := s.hasher.Verify(client.HashedSecret, secret)
spanPasswordComparison.EndWithError(err)
if err != nil {
return oidc.ErrInvalidClient().WithParent(err).WithReturnParentToClient(authz.GetFeatures(ctx).DebugOIDCParentError).WithDescription("invalid secret")
}
if updated != "" {
s.command.OIDCUpdateSecret(ctx, client.AppID, client.ProjectID, client.Settings.ResourceOwner, updated)
}
return nil
}
func (s *Server) checkOrgScopes(ctx context.Context, resourceOwner string, scopes []string) ([]string, error) {
if slices.ContainsFunc(scopes, func(scope string) bool {
return strings.HasPrefix(scope, domain.OrgDomainPrimaryScope)
}) {
org, err := s.query.OrgByID(ctx, resourceOwner)
if err != nil {
return nil, err
}
scopes = slices.DeleteFunc(scopes, func(scope string) bool {
if domain, ok := strings.CutPrefix(scope, domain.OrgDomainPrimaryScope); ok {
return domain != org.Domain
}
return false
})
}
return slices.DeleteFunc(scopes, func(scope string) bool {
if orgID, ok := strings.CutPrefix(scope, domain.OrgIDScope); ok {
return orgID != resourceOwner
}
return false
}), nil
}