zitadel/internal/query/userinfo.go
Tim Möhlmann 120ed0af73
feat(oidc): organization roles scope (#8120)
# Which Problems Are Solved

An admin / application might want to be able to reduce the amount of
roles returned in the token, for example if a user is granted to many
organizations or for specific cases where the application want to narrow
down the access for that token to a specific organization or multiple.
This can now be achieved by providing a scope with the id of the
organization, resp. multiple scopes for every organization, which should
be included.

```
urn:zitadel:iam:org:roles🆔{orgID}
```

**Note:** the new scope does not work when Introspection / Userinfo are
set to legacy mode.

# How the Problems Are Solved

The user info query now has two variants:

1. Variant that returns all organization authorization grants if the new
scope wasn't provided for backward compatibility.
2. Variant that filters the organizations based on the IDs passed in one
or more of the above scopes and returns only those authorization grants.

The query is defined as a `text/template` and both variants are rendered
once in package `init()`.

# Additional Changes

- In the integration tests `assertProjectRoleClaims` now also checks the
org IDs in the roles.

# Additional Context

- Closes #7996
2024-06-14 10:00:43 +02:00

124 lines
3.9 KiB
Go

package query
import (
"context"
"database/sql"
_ "embed"
"errors"
"strings"
"sync"
"text/template"
"github.com/zitadel/zitadel/internal/api/authz"
"github.com/zitadel/zitadel/internal/database"
"github.com/zitadel/zitadel/internal/eventstore/handler/v2"
"github.com/zitadel/zitadel/internal/query/projection"
"github.com/zitadel/zitadel/internal/telemetry/tracing"
"github.com/zitadel/zitadel/internal/zerrors"
)
// oidcUserInfoTriggerHandlers slice can only be created after zitadel
// is fully initialized, otherwise the handlers are nil.
// OnceValue takes care of creating the slice on the first request
// and than will always return the same slice on subsequent requests.
var oidcUserInfoTriggerHandlers = sync.OnceValue(func() []*handler.Handler {
return []*handler.Handler{
projection.UserProjection,
projection.UserMetadataProjection,
projection.UserGrantProjection,
projection.OrgProjection,
projection.ProjectProjection,
}
})
// TriggerOIDCUserInfoProjections triggers all projections
// relevant to userinfo queries concurrently.
func TriggerOIDCUserInfoProjections(ctx context.Context) {
triggerBatch(ctx, oidcUserInfoTriggerHandlers()...)
}
var (
//go:embed userinfo_by_id.sql
oidcUserInfoQueryTmpl string
oidcUserInfoQuery string
oidcUserInfoWithRoleOrgIDsQuery string
)
// build the two variants of the userInfo query
func init() {
tmpl := template.Must(template.New("oidcUserInfoQuery").Parse(oidcUserInfoQueryTmpl))
var buf strings.Builder
if err := tmpl.Execute(&buf, false); err != nil {
panic(err)
}
oidcUserInfoQuery = buf.String()
buf.Reset()
if err := tmpl.Execute(&buf, true); err != nil {
panic(err)
}
oidcUserInfoWithRoleOrgIDsQuery = buf.String()
buf.Reset()
}
func (q *Queries) GetOIDCUserInfo(ctx context.Context, userID string, roleAudience []string, roleOrgIDs ...string) (userInfo *OIDCUserInfo, err error) {
ctx, span := tracing.NewSpan(ctx)
defer func() { span.EndWithError(err) }()
if len(roleOrgIDs) > 0 {
userInfo, err = database.QueryJSONObject[OIDCUserInfo](ctx, q.client, oidcUserInfoWithRoleOrgIDsQuery,
userID, authz.GetInstance(ctx).InstanceID(), database.TextArray[string](roleAudience), database.TextArray[string](roleOrgIDs),
)
} else {
userInfo, err = database.QueryJSONObject[OIDCUserInfo](ctx, q.client, oidcUserInfoQuery,
userID, authz.GetInstance(ctx).InstanceID(), database.TextArray[string](roleAudience),
)
}
if errors.Is(err, sql.ErrNoRows) {
return nil, zerrors.ThrowNotFound(err, "QUERY-Eey2a", "Errors.User.NotFound")
}
if err != nil {
return nil, zerrors.ThrowInternal(err, "QUERY-Oath6", "Errors.Internal")
}
if userInfo.User == nil {
return nil, zerrors.ThrowNotFound(nil, "QUERY-ahs4S", "Errors.User.NotFound")
}
return userInfo, nil
}
type OIDCUserInfo struct {
User *User `json:"user,omitempty"`
Metadata []UserMetadata `json:"metadata,omitempty"`
Org *UserInfoOrg `json:"org,omitempty"`
UserGrants []UserGrant `json:"user_grants,omitempty"`
}
type UserInfoOrg struct {
ID string `json:"id,omitempty"`
Name string `json:"name,omitempty"`
PrimaryDomain string `json:"primary_domain,omitempty"`
}
//go:embed userinfo_client_by_id.sql
var oidcUserinfoClientQuery string
func (q *Queries) GetOIDCUserinfoClientByID(ctx context.Context, clientID string) (projectID string, projectRoleAssertion bool, err error) {
ctx, span := tracing.NewSpan(ctx)
defer func() { span.EndWithError(err) }()
scan := func(row *sql.Row) error {
err := row.Scan(&projectID, &projectRoleAssertion)
return err
}
err = q.client.QueryRowContext(ctx, scan, oidcUserinfoClientQuery, authz.GetInstance(ctx).InstanceID(), clientID)
if errors.Is(err, sql.ErrNoRows) {
return "", false, zerrors.ThrowNotFound(err, "QUERY-beeW8", "Errors.App.NotFound")
}
if err != nil {
return "", false, zerrors.ThrowInternal(err, "QUERY-Ais4r", "Errors.Internal")
}
return projectID, projectRoleAssertion, nil
}