mirror of
https://github.com/zitadel/zitadel.git
synced 2025-08-11 21:27:42 +00:00
feat: add SYSTEM_OWNER role (#6765)
* define roles and permissions * support system user memberships * don't limit system users * cleanup permissions * restrict memberships to aggregates * default to SYSTEM_OWNER * update unit tests * test: system user token test (#6778) * update unit tests * refactor: make authz testable * move session constants * cleanup * comment * comment * decode member type string to enum (#6780) * decode member type string to enum * handle all membership types * decode enums where necessary * decode member type in steps config * update system api docs * add technical advisory * tweak docs a bit * comment in comment * lint * extract token from Bearer header prefix * review changes * fix tests * fix: add fix for activityhandler * add isSystemUser * remove IsSystemUser from activity info * fix: add fix for activityhandler --------- Co-authored-by: Stefan Benz <stefan@caos.ch>
This commit is contained in:
44
internal/api/authz/access_token.go
Normal file
44
internal/api/authz/access_token.go
Normal file
@@ -0,0 +1,44 @@
|
||||
package authz
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/zitadel/zitadel/internal/telemetry/tracing"
|
||||
)
|
||||
|
||||
const (
|
||||
BearerPrefix = "Bearer "
|
||||
)
|
||||
|
||||
type MembershipsResolver interface {
|
||||
SearchMyMemberships(ctx context.Context, orgID string, shouldTriggerBulk bool) ([]*Membership, error)
|
||||
}
|
||||
|
||||
type authZRepo interface {
|
||||
MembershipsResolver
|
||||
VerifyAccessToken(ctx context.Context, token, verifierClientID, projectID string) (userID, agentID, clientID, prefLang, resourceOwner string, err error)
|
||||
VerifierClientID(ctx context.Context, name string) (clientID, projectID string, err error)
|
||||
ProjectIDAndOriginsByClientID(ctx context.Context, clientID string) (projectID string, origins []string, err error)
|
||||
ExistsOrg(ctx context.Context, id, domain string) (string, error)
|
||||
}
|
||||
|
||||
var _ AccessTokenVerifier = (*AccessTokenVerifierFromRepo)(nil)
|
||||
|
||||
type AccessTokenVerifierFromRepo struct {
|
||||
authZRepo authZRepo
|
||||
}
|
||||
|
||||
func StartAccessTokenVerifierFromRepo(authZRepo authZRepo) *AccessTokenVerifierFromRepo {
|
||||
return &AccessTokenVerifierFromRepo{authZRepo: authZRepo}
|
||||
}
|
||||
|
||||
func (a *AccessTokenVerifierFromRepo) VerifyAccessToken(ctx context.Context, token string) (userID, clientID, agentID, prefLang, resourceOwner string, err error) {
|
||||
ctx, span := tracing.NewSpan(ctx)
|
||||
defer func() { span.EndWithError(err) }()
|
||||
userID, agentID, clientID, prefLang, resourceOwner, err = a.authZRepo.VerifyAccessToken(ctx, token, "", GetInstance(ctx).ProjectID())
|
||||
return userID, clientID, agentID, prefLang, resourceOwner, err
|
||||
}
|
||||
|
||||
type client struct {
|
||||
name string
|
||||
}
|
@@ -2,19 +2,17 @@ package authz
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/zitadel/zitadel/internal/errors"
|
||||
)
|
||||
|
||||
func Test_VerifyAccessToken(t *testing.T) {
|
||||
func Test_extractBearerToken(t *testing.T) {
|
||||
|
||||
type args struct {
|
||||
ctx context.Context
|
||||
token string
|
||||
verifier *TokenVerifier
|
||||
method string
|
||||
verifier AccessTokenVerifier
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -42,23 +40,16 @@ func Test_VerifyAccessToken(t *testing.T) {
|
||||
args: args{
|
||||
ctx: context.Background(),
|
||||
token: "Bearer AUTH",
|
||||
verifier: &TokenVerifier{
|
||||
authZRepo: &testVerifier{memberships: []*Membership{}},
|
||||
clients: func() sync.Map {
|
||||
m := sync.Map{}
|
||||
m.Store("service", &client{name: "name"})
|
||||
return m
|
||||
}(),
|
||||
authMethods: MethodMapping{"/service/method": Option{Permission: "authenticated"}},
|
||||
},
|
||||
method: "/service/method",
|
||||
verifier: AccessTokenVerifierFunc(func(context.Context, string) (string, string, string, string, string, error) {
|
||||
return "", "", "", "", "", nil
|
||||
}),
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
_, _, _, _, _, err := verifyAccessToken(tt.args.ctx, tt.args.token, tt.args.verifier, tt.args.method)
|
||||
_, err := extractBearerToken(tt.args.token)
|
||||
if tt.wantErr && err == nil {
|
||||
t.Errorf("got wrong result, should get err: actual: %v ", err)
|
||||
}
|
69
internal/api/authz/api_token_verifier.go
Normal file
69
internal/api/authz/api_token_verifier.go
Normal file
@@ -0,0 +1,69 @@
|
||||
package authz
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
|
||||
"github.com/zitadel/zitadel/internal/telemetry/tracing"
|
||||
)
|
||||
|
||||
// TODO: Define interfaces where they are accepted
|
||||
type APITokenVerifier interface {
|
||||
AccessTokenVerifier
|
||||
SystemTokenVerifier
|
||||
RegisterServer(appName, methodPrefix string, mappings MethodMapping)
|
||||
CheckAuthMethod(method string) (Option, bool)
|
||||
ProjectIDAndOriginsByClientID(ctx context.Context, clientID string) (_ string, _ []string, err error)
|
||||
ExistsOrg(ctx context.Context, id, domain string) (orgID string, err error)
|
||||
SearchMyMemberships(ctx context.Context, orgID string, shouldTriggerBulk bool) (_ []*Membership, err error)
|
||||
}
|
||||
|
||||
type ApiTokenVerifier struct {
|
||||
AccessTokenVerifier
|
||||
SystemTokenVerifier
|
||||
authZRepo authZRepo
|
||||
clients sync.Map
|
||||
authMethods MethodMapping
|
||||
}
|
||||
|
||||
func StartAPITokenVerifier(authZRepo authZRepo, accessTokenVerifier AccessTokenVerifier, systemTokenVerifier SystemTokenVerifier) *ApiTokenVerifier {
|
||||
return &ApiTokenVerifier{
|
||||
authZRepo: authZRepo,
|
||||
SystemTokenVerifier: systemTokenVerifier,
|
||||
AccessTokenVerifier: accessTokenVerifier,
|
||||
}
|
||||
}
|
||||
|
||||
func (v *ApiTokenVerifier) RegisterServer(appName, methodPrefix string, mappings MethodMapping) {
|
||||
v.clients.Store(methodPrefix, &client{name: appName})
|
||||
if v.authMethods == nil {
|
||||
v.authMethods = make(map[string]Option)
|
||||
}
|
||||
for method, option := range mappings {
|
||||
v.authMethods[method] = option
|
||||
}
|
||||
}
|
||||
|
||||
func (v *ApiTokenVerifier) SearchMyMemberships(ctx context.Context, orgID string, shouldTriggerBulk bool) (_ []*Membership, err error) {
|
||||
ctx, span := tracing.NewSpan(ctx)
|
||||
defer func() { span.EndWithError(err) }()
|
||||
return v.authZRepo.SearchMyMemberships(ctx, orgID, shouldTriggerBulk)
|
||||
}
|
||||
|
||||
func (v *ApiTokenVerifier) ProjectIDAndOriginsByClientID(ctx context.Context, clientID string) (_ string, _ []string, err error) {
|
||||
ctx, span := tracing.NewSpan(ctx)
|
||||
defer func() { span.EndWithError(err) }()
|
||||
|
||||
return v.authZRepo.ProjectIDAndOriginsByClientID(ctx, clientID)
|
||||
}
|
||||
|
||||
func (v *ApiTokenVerifier) ExistsOrg(ctx context.Context, id, domain string) (orgID string, err error) {
|
||||
ctx, span := tracing.NewSpan(ctx)
|
||||
defer func() { span.EndWithError(err) }()
|
||||
return v.authZRepo.ExistsOrg(ctx, id, domain)
|
||||
}
|
||||
|
||||
func (v *ApiTokenVerifier) CheckAuthMethod(method string) (Option, bool) {
|
||||
authOpt, ok := v.authMethods[method]
|
||||
return authOpt, ok
|
||||
}
|
@@ -19,11 +19,11 @@ const (
|
||||
// - the organisation (**either** provided by ID or verified domain) exists
|
||||
// - the user is permitted to call the requested endpoint (permission option in proto)
|
||||
// it will pass the [CtxData] and permission of the user into the ctx [context.Context]
|
||||
func CheckUserAuthorization(ctx context.Context, req interface{}, token, orgID, orgDomain string, verifier *TokenVerifier, authConfig Config, requiredAuthOption Option, method string) (ctxSetter func(context.Context) context.Context, err error) {
|
||||
func CheckUserAuthorization(ctx context.Context, req interface{}, token, orgID, orgDomain string, verifier APITokenVerifier, authConfig Config, requiredAuthOption Option, method string) (ctxSetter func(context.Context) context.Context, err error) {
|
||||
ctx, span := tracing.NewServerInterceptorSpan(ctx)
|
||||
defer func() { span.EndWithError(err) }()
|
||||
|
||||
ctxData, err := VerifyTokenAndCreateCtxData(ctx, token, orgID, orgDomain, verifier, method)
|
||||
ctxData, err := VerifyTokenAndCreateCtxData(ctx, token, orgID, orgDomain, verifier)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
@@ -1,12 +1,15 @@
|
||||
//go:generate enumer -type MemberType -trimprefix MemberType
|
||||
|
||||
package authz
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"github.com/zitadel/zitadel/internal/api/grpc"
|
||||
http_util "github.com/zitadel/zitadel/internal/api/http"
|
||||
"github.com/zitadel/zitadel/internal/errors"
|
||||
zitadel_errors "github.com/zitadel/zitadel/internal/errors"
|
||||
"github.com/zitadel/zitadel/internal/telemetry/tracing"
|
||||
)
|
||||
|
||||
@@ -26,10 +29,11 @@ type CtxData struct {
|
||||
AgentID string
|
||||
PreferredLanguage string
|
||||
ResourceOwner string
|
||||
SystemMemberships Memberships
|
||||
}
|
||||
|
||||
func (ctxData CtxData) IsZero() bool {
|
||||
return ctxData.UserID == "" || ctxData.OrgID == ""
|
||||
return ctxData.UserID == "" || ctxData.OrgID == "" && ctxData.SystemMemberships == nil
|
||||
}
|
||||
|
||||
type Grants []*Grant
|
||||
@@ -54,29 +58,68 @@ type MemberType int32
|
||||
|
||||
const (
|
||||
MemberTypeUnspecified MemberType = iota
|
||||
MemberTypeOrganisation
|
||||
MemberTypeOrganization
|
||||
MemberTypeProject
|
||||
MemberTypeProjectGrant
|
||||
MemberTypeIam
|
||||
MemberTypeIAM
|
||||
MemberTypeSystem
|
||||
)
|
||||
|
||||
func VerifyTokenAndCreateCtxData(ctx context.Context, token, orgID, orgDomain string, t *TokenVerifier, method string) (_ CtxData, err error) {
|
||||
type TokenVerifier interface {
|
||||
ExistsOrg(ctx context.Context, id, domain string) (string, error)
|
||||
ProjectIDAndOriginsByClientID(ctx context.Context, clientID string) (projectID string, origins []string, err error)
|
||||
AccessTokenVerifier
|
||||
SystemTokenVerifier
|
||||
}
|
||||
|
||||
type AccessTokenVerifier interface {
|
||||
VerifyAccessToken(ctx context.Context, token string) (userID, clientID, agentID, prefLan, resourceOwner string, err error)
|
||||
}
|
||||
|
||||
// AccessTokenVerifierFunc implements the SystemTokenVerifier interface so that a function can be used as a AccessTokenVerifier.
|
||||
type AccessTokenVerifierFunc func(context.Context, string) (string, string, string, string, string, error)
|
||||
|
||||
func (a AccessTokenVerifierFunc) VerifyAccessToken(ctx context.Context, token string) (string, string, string, string, string, error) {
|
||||
return a(ctx, token)
|
||||
}
|
||||
|
||||
type SystemTokenVerifier interface {
|
||||
VerifySystemToken(ctx context.Context, token string, orgID string) (matchingMemberships Memberships, userID string, err error)
|
||||
}
|
||||
|
||||
// SystemTokenVerifierFunc implements the SystemTokenVerifier interface so that a function can be used as a SystemTokenVerifier.
|
||||
type SystemTokenVerifierFunc func(context.Context, string, string) (Memberships, string, error)
|
||||
|
||||
func (s SystemTokenVerifierFunc) VerifySystemToken(ctx context.Context, token string, orgID string) (Memberships, string, error) {
|
||||
return s(ctx, token, orgID)
|
||||
}
|
||||
|
||||
func VerifyTokenAndCreateCtxData(ctx context.Context, token, orgID, orgDomain string, t APITokenVerifier) (_ CtxData, err error) {
|
||||
ctx, span := tracing.NewSpan(ctx)
|
||||
defer func() { span.EndWithError(err) }()
|
||||
|
||||
userID, clientID, agentID, prefLang, resourceOwner, err := verifyAccessToken(ctx, token, t, method)
|
||||
tokenWOBearer, err := extractBearerToken(token)
|
||||
if err != nil {
|
||||
return CtxData{}, err
|
||||
}
|
||||
if strings.HasPrefix(method, "/zitadel.system.v1.SystemService") {
|
||||
return CtxData{UserID: userID}, nil
|
||||
userID, clientID, agentID, prefLang, resourceOwner, err := t.VerifyAccessToken(ctx, tokenWOBearer)
|
||||
var sysMemberships Memberships
|
||||
if err != nil && !zitadel_errors.IsUnauthenticated(err) {
|
||||
return CtxData{}, err
|
||||
}
|
||||
if err != nil {
|
||||
var sysTokenErr error
|
||||
sysMemberships, userID, sysTokenErr = t.VerifySystemToken(ctx, tokenWOBearer, orgID)
|
||||
err = errors.Join(err, sysTokenErr)
|
||||
if sysTokenErr != nil || sysMemberships == nil {
|
||||
return CtxData{}, err
|
||||
}
|
||||
}
|
||||
var projectID string
|
||||
var origins []string
|
||||
if clientID != "" {
|
||||
projectID, origins, err = t.ProjectIDAndOriginsByClientID(ctx, clientID)
|
||||
if err != nil {
|
||||
return CtxData{}, errors.ThrowPermissionDenied(err, "AUTH-GHpw2", "could not read projectid by clientid")
|
||||
return CtxData{}, zitadel_errors.ThrowPermissionDenied(err, "AUTH-GHpw2", "could not read projectid by clientid")
|
||||
}
|
||||
// We used to check origins for every token, but service users shouldn't be used publicly (native app / SPA).
|
||||
// Therefore, mostly won't send an origin and aren't able to configure them anyway.
|
||||
@@ -88,21 +131,22 @@ func VerifyTokenAndCreateCtxData(ctx context.Context, token, orgID, orgDomain st
|
||||
if orgID == "" && orgDomain == "" {
|
||||
orgID = resourceOwner
|
||||
}
|
||||
|
||||
verifiedOrgID, err := t.ExistsOrg(ctx, orgID, orgDomain)
|
||||
if err != nil {
|
||||
return CtxData{}, errors.ThrowPermissionDenied(nil, "AUTH-Bs7Ds", "Organisation doesn't exist")
|
||||
// System API calls dont't have a resource owner
|
||||
if orgID != "" {
|
||||
orgID, err = t.ExistsOrg(ctx, orgID, orgDomain)
|
||||
if err != nil {
|
||||
return CtxData{}, zitadel_errors.ThrowPermissionDenied(nil, "AUTH-Bs7Ds", "Organisation doesn't exist")
|
||||
}
|
||||
}
|
||||
|
||||
return CtxData{
|
||||
UserID: userID,
|
||||
OrgID: verifiedOrgID,
|
||||
OrgID: orgID,
|
||||
ProjectID: projectID,
|
||||
AgentID: agentID,
|
||||
PreferredLanguage: prefLang,
|
||||
ResourceOwner: resourceOwner,
|
||||
SystemMemberships: sysMemberships,
|
||||
}, nil
|
||||
|
||||
}
|
||||
|
||||
func SetCtxData(ctx context.Context, ctxData CtxData) context.Context {
|
||||
@@ -119,11 +163,6 @@ func GetRequestPermissionsFromCtx(ctx context.Context) []string {
|
||||
return ctxPermission
|
||||
}
|
||||
|
||||
func GetAllPermissionsFromCtx(ctx context.Context) []string {
|
||||
ctxPermission, _ := ctx.Value(allPermissionsKey).([]string)
|
||||
return ctxPermission
|
||||
}
|
||||
|
||||
func checkOrigin(ctx context.Context, origins []string) error {
|
||||
origin := grpc.GetGatewayHeader(ctx, http_util.Origin)
|
||||
if origin == "" {
|
||||
@@ -135,5 +174,13 @@ func checkOrigin(ctx context.Context, origins []string) error {
|
||||
if http_util.IsOriginAllowed(origins, origin) {
|
||||
return nil
|
||||
}
|
||||
return errors.ThrowPermissionDenied(nil, "AUTH-DZG21", "Errors.OriginNotAllowed")
|
||||
return zitadel_errors.ThrowPermissionDenied(nil, "AUTH-DZG21", "Errors.OriginNotAllowed")
|
||||
}
|
||||
|
||||
func extractBearerToken(token string) (part string, err error) {
|
||||
parts := strings.Split(token, BearerPrefix)
|
||||
if len(parts) != 2 {
|
||||
return "", zitadel_errors.ThrowUnauthenticated(nil, "AUTH-7fs1e", "invalid auth header")
|
||||
}
|
||||
return parts[1], nil
|
||||
}
|
||||
|
94
internal/api/authz/membertype_enumer.go
Normal file
94
internal/api/authz/membertype_enumer.go
Normal file
@@ -0,0 +1,94 @@
|
||||
// Code generated by "enumer -type MemberType -trimprefix MemberType"; DO NOT EDIT.
|
||||
|
||||
package authz
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const _MemberTypeName = "UnspecifiedOrganizationProjectProjectGrantIAMSystem"
|
||||
|
||||
var _MemberTypeIndex = [...]uint8{0, 11, 23, 30, 42, 45, 51}
|
||||
|
||||
const _MemberTypeLowerName = "unspecifiedorganizationprojectprojectgrantiamsystem"
|
||||
|
||||
func (i MemberType) String() string {
|
||||
if i < 0 || i >= MemberType(len(_MemberTypeIndex)-1) {
|
||||
return fmt.Sprintf("MemberType(%d)", i)
|
||||
}
|
||||
return _MemberTypeName[_MemberTypeIndex[i]:_MemberTypeIndex[i+1]]
|
||||
}
|
||||
|
||||
// An "invalid array index" compiler error signifies that the constant values have changed.
|
||||
// Re-run the stringer command to generate them again.
|
||||
func _MemberTypeNoOp() {
|
||||
var x [1]struct{}
|
||||
_ = x[MemberTypeUnspecified-(0)]
|
||||
_ = x[MemberTypeOrganization-(1)]
|
||||
_ = x[MemberTypeProject-(2)]
|
||||
_ = x[MemberTypeProjectGrant-(3)]
|
||||
_ = x[MemberTypeIAM-(4)]
|
||||
_ = x[MemberTypeSystem-(5)]
|
||||
}
|
||||
|
||||
var _MemberTypeValues = []MemberType{MemberTypeUnspecified, MemberTypeOrganization, MemberTypeProject, MemberTypeProjectGrant, MemberTypeIAM, MemberTypeSystem}
|
||||
|
||||
var _MemberTypeNameToValueMap = map[string]MemberType{
|
||||
_MemberTypeName[0:11]: MemberTypeUnspecified,
|
||||
_MemberTypeLowerName[0:11]: MemberTypeUnspecified,
|
||||
_MemberTypeName[11:23]: MemberTypeOrganization,
|
||||
_MemberTypeLowerName[11:23]: MemberTypeOrganization,
|
||||
_MemberTypeName[23:30]: MemberTypeProject,
|
||||
_MemberTypeLowerName[23:30]: MemberTypeProject,
|
||||
_MemberTypeName[30:42]: MemberTypeProjectGrant,
|
||||
_MemberTypeLowerName[30:42]: MemberTypeProjectGrant,
|
||||
_MemberTypeName[42:45]: MemberTypeIAM,
|
||||
_MemberTypeLowerName[42:45]: MemberTypeIAM,
|
||||
_MemberTypeName[45:51]: MemberTypeSystem,
|
||||
_MemberTypeLowerName[45:51]: MemberTypeSystem,
|
||||
}
|
||||
|
||||
var _MemberTypeNames = []string{
|
||||
_MemberTypeName[0:11],
|
||||
_MemberTypeName[11:23],
|
||||
_MemberTypeName[23:30],
|
||||
_MemberTypeName[30:42],
|
||||
_MemberTypeName[42:45],
|
||||
_MemberTypeName[45:51],
|
||||
}
|
||||
|
||||
// MemberTypeString retrieves an enum value from the enum constants string name.
|
||||
// Throws an error if the param is not part of the enum.
|
||||
func MemberTypeString(s string) (MemberType, error) {
|
||||
if val, ok := _MemberTypeNameToValueMap[s]; ok {
|
||||
return val, nil
|
||||
}
|
||||
|
||||
if val, ok := _MemberTypeNameToValueMap[strings.ToLower(s)]; ok {
|
||||
return val, nil
|
||||
}
|
||||
return 0, fmt.Errorf("%s does not belong to MemberType values", s)
|
||||
}
|
||||
|
||||
// MemberTypeValues returns all values of the enum
|
||||
func MemberTypeValues() []MemberType {
|
||||
return _MemberTypeValues
|
||||
}
|
||||
|
||||
// MemberTypeStrings returns a slice of all String values of the enum
|
||||
func MemberTypeStrings() []string {
|
||||
strs := make([]string, len(_MemberTypeNames))
|
||||
copy(strs, _MemberTypeNames)
|
||||
return strs
|
||||
}
|
||||
|
||||
// IsAMemberType returns "true" if the value is listed in the enum definition. "false" otherwise
|
||||
func (i MemberType) IsAMemberType() bool {
|
||||
for _, v := range _MemberTypeValues {
|
||||
if i == v {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
@@ -30,6 +30,11 @@ func getUserPermissions(ctx context.Context, resolver MembershipsResolver, requi
|
||||
return nil, nil, errors.ThrowUnauthenticated(nil, "AUTH-rKLWEH", "context missing")
|
||||
}
|
||||
|
||||
if ctxData.SystemMemberships != nil {
|
||||
requestedPermissions, allPermissions = mapMembershipsToPermissions(requiredPerm, ctxData.SystemMemberships, roleMappings)
|
||||
return requestedPermissions, allPermissions, nil
|
||||
}
|
||||
|
||||
ctx = context.WithValue(ctx, dataKey, ctxData)
|
||||
memberships, err := resolver.SearchMyMemberships(ctx, orgID, false)
|
||||
if err != nil {
|
||||
|
@@ -7,33 +7,6 @@ import (
|
||||
caos_errs "github.com/zitadel/zitadel/internal/errors"
|
||||
)
|
||||
|
||||
func getTestCtx(userID, orgID string) context.Context {
|
||||
return context.WithValue(context.Background(), dataKey, CtxData{UserID: userID, OrgID: orgID})
|
||||
}
|
||||
|
||||
type testVerifier struct {
|
||||
memberships []*Membership
|
||||
}
|
||||
|
||||
func (v *testVerifier) VerifyAccessToken(ctx context.Context, token, clientID, projectID string) (string, string, string, string, string, error) {
|
||||
return "userID", "agentID", "clientID", "de", "orgID", nil
|
||||
}
|
||||
func (v *testVerifier) SearchMyMemberships(ctx context.Context, orgID string, _ bool) ([]*Membership, error) {
|
||||
return v.memberships, nil
|
||||
}
|
||||
|
||||
func (v *testVerifier) ProjectIDAndOriginsByClientID(ctx context.Context, clientID string) (string, []string, error) {
|
||||
return "", nil, nil
|
||||
}
|
||||
|
||||
func (v *testVerifier) ExistsOrg(ctx context.Context, orgID, domain string) (string, error) {
|
||||
return orgID, nil
|
||||
}
|
||||
|
||||
func (v *testVerifier) VerifierClientID(ctx context.Context, appName string) (string, string, error) {
|
||||
return "clientID", "projectID", nil
|
||||
}
|
||||
|
||||
func equalStringArray(a, b []string) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
@@ -46,12 +19,18 @@ func equalStringArray(a, b []string) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
type membershipsResolverFunc func(ctx context.Context, orgID string, shouldTriggerBulk bool) ([]*Membership, error)
|
||||
|
||||
func (m membershipsResolverFunc) SearchMyMemberships(ctx context.Context, orgID string, shouldTriggerBulk bool) ([]*Membership, error) {
|
||||
return m(ctx, orgID, shouldTriggerBulk)
|
||||
}
|
||||
|
||||
func Test_GetUserPermissions(t *testing.T) {
|
||||
type args struct {
|
||||
ctxData CtxData
|
||||
verifier *TokenVerifier
|
||||
requiredPerm string
|
||||
authConfig Config
|
||||
ctxData CtxData
|
||||
membershipsResolver MembershipsResolver
|
||||
requiredPerm string
|
||||
authConfig Config
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -64,11 +43,9 @@ func Test_GetUserPermissions(t *testing.T) {
|
||||
name: "Empty Context",
|
||||
args: args{
|
||||
ctxData: CtxData{},
|
||||
verifier: Start(&testVerifier{memberships: []*Membership{
|
||||
{
|
||||
Roles: []string{"ORG_OWNER"},
|
||||
},
|
||||
}}, "", nil),
|
||||
membershipsResolver: membershipsResolverFunc(func(ctx context.Context, orgID string, shouldTriggerBulk bool) ([]*Membership, error) {
|
||||
return []*Membership{{Roles: []string{"ORG_OWNER"}}}, nil
|
||||
}),
|
||||
requiredPerm: "project.read",
|
||||
authConfig: Config{
|
||||
RolePermissionMappings: []RoleMapping{
|
||||
@@ -90,8 +67,10 @@ func Test_GetUserPermissions(t *testing.T) {
|
||||
{
|
||||
name: "No Grants",
|
||||
args: args{
|
||||
ctxData: CtxData{},
|
||||
verifier: Start(&testVerifier{memberships: []*Membership{}}, "", nil),
|
||||
ctxData: CtxData{},
|
||||
membershipsResolver: membershipsResolverFunc(func(ctx context.Context, orgID string, shouldTriggerBulk bool) ([]*Membership, error) {
|
||||
return []*Membership{}, nil
|
||||
}),
|
||||
requiredPerm: "project.read",
|
||||
authConfig: Config{
|
||||
RolePermissionMappings: []RoleMapping{
|
||||
@@ -112,14 +91,16 @@ func Test_GetUserPermissions(t *testing.T) {
|
||||
name: "Get Permissions",
|
||||
args: args{
|
||||
ctxData: CtxData{UserID: "userID", OrgID: "orgID"},
|
||||
verifier: Start(&testVerifier{memberships: []*Membership{
|
||||
{
|
||||
AggregateID: "IAM",
|
||||
ObjectID: "IAM",
|
||||
MemberType: MemberTypeIam,
|
||||
Roles: []string{"IAM_OWNER"},
|
||||
},
|
||||
}}, "", nil),
|
||||
membershipsResolver: membershipsResolverFunc(func(ctx context.Context, orgID string, shouldTriggerBulk bool) ([]*Membership, error) {
|
||||
return []*Membership{
|
||||
{
|
||||
AggregateID: "IAM",
|
||||
ObjectID: "IAM",
|
||||
MemberType: MemberTypeIAM,
|
||||
Roles: []string{"IAM_OWNER"},
|
||||
},
|
||||
}, nil
|
||||
}),
|
||||
requiredPerm: "project.read",
|
||||
authConfig: Config{
|
||||
RolePermissionMappings: []RoleMapping{
|
||||
@@ -139,7 +120,7 @@ func Test_GetUserPermissions(t *testing.T) {
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
_, perms, err := getUserPermissions(context.Background(), tt.args.verifier, tt.args.requiredPerm, tt.args.authConfig.RolePermissionMappings, tt.args.ctxData, tt.args.ctxData.OrgID)
|
||||
_, perms, err := getUserPermissions(context.Background(), tt.args.membershipsResolver, tt.args.requiredPerm, tt.args.authConfig.RolePermissionMappings, tt.args.ctxData, tt.args.ctxData.OrgID)
|
||||
|
||||
if tt.wantErr && err == nil {
|
||||
t.Errorf("got wrong result, should get err: actual: %v ", err)
|
||||
@@ -176,7 +157,7 @@ func Test_MapMembershipToPermissions(t *testing.T) {
|
||||
{
|
||||
AggregateID: "1",
|
||||
ObjectID: "1",
|
||||
MemberType: MemberTypeOrganisation,
|
||||
MemberType: MemberTypeOrganization,
|
||||
Roles: []string{"ORG_OWNER"},
|
||||
},
|
||||
},
|
||||
@@ -204,7 +185,7 @@ func Test_MapMembershipToPermissions(t *testing.T) {
|
||||
{
|
||||
AggregateID: "1",
|
||||
ObjectID: "1",
|
||||
MemberType: MemberTypeOrganisation,
|
||||
MemberType: MemberTypeOrganization,
|
||||
Roles: []string{"ORG_OWNER"},
|
||||
},
|
||||
},
|
||||
@@ -232,13 +213,13 @@ func Test_MapMembershipToPermissions(t *testing.T) {
|
||||
{
|
||||
AggregateID: "1",
|
||||
ObjectID: "1",
|
||||
MemberType: MemberTypeOrganisation,
|
||||
MemberType: MemberTypeOrganization,
|
||||
Roles: []string{"ORG_OWNER"},
|
||||
},
|
||||
{
|
||||
AggregateID: "IAM",
|
||||
ObjectID: "IAM",
|
||||
MemberType: MemberTypeIam,
|
||||
MemberType: MemberTypeIAM,
|
||||
Roles: []string{"IAM_OWNER"},
|
||||
},
|
||||
},
|
||||
@@ -266,7 +247,7 @@ func Test_MapMembershipToPermissions(t *testing.T) {
|
||||
{
|
||||
AggregateID: "2",
|
||||
ObjectID: "2",
|
||||
MemberType: MemberTypeOrganisation,
|
||||
MemberType: MemberTypeOrganization,
|
||||
Roles: []string{"ORG_OWNER"},
|
||||
},
|
||||
{
|
||||
@@ -327,7 +308,7 @@ func Test_MapMembershipToPerm(t *testing.T) {
|
||||
membership: &Membership{
|
||||
AggregateID: "Org",
|
||||
ObjectID: "Org",
|
||||
MemberType: MemberTypeOrganisation,
|
||||
MemberType: MemberTypeOrganization,
|
||||
Roles: []string{"ORG_OWNER"},
|
||||
},
|
||||
authConfig: Config{
|
||||
@@ -355,7 +336,7 @@ func Test_MapMembershipToPerm(t *testing.T) {
|
||||
membership: &Membership{
|
||||
AggregateID: "Org",
|
||||
ObjectID: "Org",
|
||||
MemberType: MemberTypeOrganisation,
|
||||
MemberType: MemberTypeOrganization,
|
||||
Roles: []string{"ORG_OWNER"},
|
||||
},
|
||||
authConfig: Config{
|
||||
|
32
internal/api/authz/session_token.go
Normal file
32
internal/api/authz/session_token.go
Normal file
@@ -0,0 +1,32 @@
|
||||
package authz
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
|
||||
"github.com/zitadel/zitadel/internal/crypto"
|
||||
zitadel_errors "github.com/zitadel/zitadel/internal/errors"
|
||||
"github.com/zitadel/zitadel/internal/telemetry/tracing"
|
||||
)
|
||||
|
||||
const (
|
||||
SessionTokenPrefix = "sess_"
|
||||
SessionTokenFormat = SessionTokenPrefix + "%s:%s"
|
||||
)
|
||||
|
||||
func SessionTokenVerifier(algorithm crypto.EncryptionAlgorithm) func(ctx context.Context, sessionToken, sessionID, tokenID string) (err error) {
|
||||
return func(ctx context.Context, sessionToken, sessionID, tokenID string) (err error) {
|
||||
decodedToken, err := base64.RawURLEncoding.DecodeString(sessionToken)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, spanPasswordComparison := tracing.NewNamedSpan(ctx, "crypto.CompareHash")
|
||||
token, err := algorithm.DecryptString(decodedToken, algorithm.EncryptionKeyID())
|
||||
spanPasswordComparison.EndWithError(err)
|
||||
if err != nil || token != fmt.Sprintf(SessionTokenFormat, sessionID, tokenID) {
|
||||
return zitadel_errors.ThrowPermissionDenied(err, "COMMAND-sGr42", "Errors.Session.Token.Invalid")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
117
internal/api/authz/system_token.go
Normal file
117
internal/api/authz/system_token.go
Normal file
@@ -0,0 +1,117 @@
|
||||
package authz
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rsa"
|
||||
"errors"
|
||||
"os"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/go-jose/go-jose/v3"
|
||||
"github.com/zitadel/oidc/v3/pkg/op"
|
||||
|
||||
"github.com/zitadel/zitadel/internal/crypto"
|
||||
zitadel_errors "github.com/zitadel/zitadel/internal/errors"
|
||||
)
|
||||
|
||||
var _ SystemTokenVerifier = (*SystemTokenVerifierFromConfig)(nil)
|
||||
|
||||
type SystemTokenVerifierFromConfig struct {
|
||||
systemJWTProfile *op.JWTProfileVerifier
|
||||
systemUsers map[string]Memberships
|
||||
}
|
||||
|
||||
func StartSystemTokenVerifierFromConfig(issuer string, keys map[string]*SystemAPIUser) (*SystemTokenVerifierFromConfig, error) {
|
||||
systemUsers := make(map[string]Memberships, len(keys))
|
||||
for userID, key := range keys {
|
||||
if len(key.Memberships) == 0 {
|
||||
systemUsers[userID] = Memberships{{MemberType: MemberTypeSystem, Roles: []string{"SYSTEM_OWNER"}}}
|
||||
continue
|
||||
}
|
||||
for _, membership := range key.Memberships {
|
||||
switch membership.MemberType {
|
||||
case MemberTypeSystem, MemberTypeIAM, MemberTypeOrganization:
|
||||
systemUsers[userID] = key.Memberships
|
||||
case MemberTypeUnspecified, MemberTypeProject, MemberTypeProjectGrant:
|
||||
return nil, errors.New("for system users, only the membership types System, IAM and Organization are supported")
|
||||
default:
|
||||
return nil, errors.New("unknown membership type")
|
||||
}
|
||||
}
|
||||
}
|
||||
return &SystemTokenVerifierFromConfig{
|
||||
systemJWTProfile: op.NewJWTProfileVerifier(
|
||||
&systemJWTStorage{
|
||||
keys: keys,
|
||||
cachedKeys: make(map[string]*rsa.PublicKey),
|
||||
},
|
||||
issuer,
|
||||
1*time.Hour,
|
||||
time.Second,
|
||||
),
|
||||
systemUsers: systemUsers,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *SystemTokenVerifierFromConfig) VerifySystemToken(ctx context.Context, token string, orgID string) (matchingMemberships Memberships, userID string, err error) {
|
||||
jwtReq, err := op.VerifyJWTAssertion(ctx, token, s.systemJWTProfile)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
systemUserMemberships, ok := s.systemUsers[jwtReq.Subject]
|
||||
if !ok {
|
||||
return nil, "", zitadel_errors.ThrowPermissionDenied(nil, "AUTH-Bohd2", "Errors.User.UserIDWrong")
|
||||
}
|
||||
matchingMemberships = make(Memberships, 0, len(systemUserMemberships))
|
||||
for _, membership := range systemUserMemberships {
|
||||
if membership.MemberType == MemberTypeSystem ||
|
||||
membership.MemberType == MemberTypeIAM && GetInstance(ctx).InstanceID() == membership.AggregateID ||
|
||||
membership.MemberType == MemberTypeOrganization && orgID == membership.AggregateID {
|
||||
matchingMemberships = append(matchingMemberships, membership)
|
||||
}
|
||||
}
|
||||
return matchingMemberships, jwtReq.Subject, nil
|
||||
}
|
||||
|
||||
type systemJWTStorage struct {
|
||||
keys map[string]*SystemAPIUser
|
||||
mutex sync.Mutex
|
||||
cachedKeys map[string]*rsa.PublicKey
|
||||
}
|
||||
|
||||
type SystemAPIUser struct {
|
||||
Path string // if a path is specified, the key will be read from that path
|
||||
KeyData []byte // else you can also specify the data directly in the KeyData
|
||||
Memberships Memberships
|
||||
}
|
||||
|
||||
func (s *SystemAPIUser) readKey() (*rsa.PublicKey, error) {
|
||||
if s.Path != "" {
|
||||
var err error
|
||||
s.KeyData, err = os.ReadFile(s.Path)
|
||||
if err != nil {
|
||||
return nil, zitadel_errors.ThrowInternal(err, "AUTHZ-JK31F", "Errors.NotFound")
|
||||
}
|
||||
}
|
||||
return crypto.BytesToPublicKey(s.KeyData)
|
||||
}
|
||||
|
||||
func (s *systemJWTStorage) GetKeyByIDAndClientID(_ context.Context, _, userID string) (*jose.JSONWebKey, error) {
|
||||
cachedKey, ok := s.cachedKeys[userID]
|
||||
if ok {
|
||||
return &jose.JSONWebKey{KeyID: userID, Key: cachedKey}, nil
|
||||
}
|
||||
key, ok := s.keys[userID]
|
||||
if !ok {
|
||||
return nil, zitadel_errors.ThrowNotFound(nil, "AUTHZ-asfd3", "Errors.User.NotFound")
|
||||
}
|
||||
s.mutex.Lock()
|
||||
defer s.mutex.Unlock()
|
||||
publicKey, err := key.readKey()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.cachedKeys[userID] = publicKey
|
||||
return &jose.JSONWebKey{KeyID: userID, Key: publicKey}, nil
|
||||
}
|
@@ -1,188 +0,0 @@
|
||||
package authz
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rsa"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/go-jose/go-jose/v3"
|
||||
"github.com/zitadel/oidc/v3/pkg/op"
|
||||
|
||||
"github.com/zitadel/zitadel/internal/crypto"
|
||||
caos_errs "github.com/zitadel/zitadel/internal/errors"
|
||||
"github.com/zitadel/zitadel/internal/telemetry/tracing"
|
||||
)
|
||||
|
||||
const (
|
||||
BearerPrefix = "Bearer "
|
||||
SessionTokenPrefix = "sess_"
|
||||
SessionTokenFormat = SessionTokenPrefix + "%s:%s"
|
||||
)
|
||||
|
||||
type TokenVerifier struct {
|
||||
authZRepo authZRepo
|
||||
clients sync.Map
|
||||
authMethods MethodMapping
|
||||
systemJWTProfile *op.JWTProfileVerifier
|
||||
}
|
||||
|
||||
type MembershipsResolver interface {
|
||||
SearchMyMemberships(ctx context.Context, orgID string, shouldTriggerBulk bool) ([]*Membership, error)
|
||||
}
|
||||
|
||||
type authZRepo interface {
|
||||
MembershipsResolver
|
||||
VerifyAccessToken(ctx context.Context, token, verifierClientID, projectID string) (userID, agentID, clientID, prefLang, resourceOwner string, err error)
|
||||
VerifierClientID(ctx context.Context, name string) (clientID, projectID string, err error)
|
||||
ProjectIDAndOriginsByClientID(ctx context.Context, clientID string) (projectID string, origins []string, err error)
|
||||
ExistsOrg(ctx context.Context, id, domain string) (string, error)
|
||||
}
|
||||
|
||||
func Start(authZRepo authZRepo, issuer string, keys map[string]*SystemAPIUser) (v *TokenVerifier) {
|
||||
return &TokenVerifier{
|
||||
authZRepo: authZRepo,
|
||||
systemJWTProfile: op.NewJWTProfileVerifier(
|
||||
&systemJWTStorage{
|
||||
keys: keys,
|
||||
cachedKeys: make(map[string]*rsa.PublicKey),
|
||||
},
|
||||
issuer,
|
||||
1*time.Hour,
|
||||
time.Second,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
func (v *TokenVerifier) VerifyAccessToken(ctx context.Context, token string, method string) (userID, clientID, agentID, prefLang, resourceOwner string, err error) {
|
||||
if strings.HasPrefix(method, "/zitadel.system.v1.SystemService") {
|
||||
userID, err := v.verifySystemToken(ctx, token)
|
||||
if err != nil {
|
||||
return "", "", "", "", "", err
|
||||
}
|
||||
return userID, "", "", "", "", nil
|
||||
}
|
||||
userID, agentID, clientID, prefLang, resourceOwner, err = v.authZRepo.VerifyAccessToken(ctx, token, "", GetInstance(ctx).ProjectID())
|
||||
return userID, clientID, agentID, prefLang, resourceOwner, err
|
||||
}
|
||||
|
||||
func (v *TokenVerifier) verifySystemToken(ctx context.Context, token string) (string, error) {
|
||||
jwtReq, err := op.VerifyJWTAssertion(ctx, token, v.systemJWTProfile)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return jwtReq.Subject, nil
|
||||
}
|
||||
|
||||
type systemJWTStorage struct {
|
||||
keys map[string]*SystemAPIUser
|
||||
mutex sync.Mutex
|
||||
cachedKeys map[string]*rsa.PublicKey
|
||||
}
|
||||
|
||||
type SystemAPIUser struct {
|
||||
Path string //if a path is specified, the key will be read from that path
|
||||
KeyData []byte //else you can also specify the data directly in the KeyData
|
||||
}
|
||||
|
||||
func (s *SystemAPIUser) readKey() (*rsa.PublicKey, error) {
|
||||
if s.Path != "" {
|
||||
var err error
|
||||
s.KeyData, err = os.ReadFile(s.Path)
|
||||
if err != nil {
|
||||
return nil, caos_errs.ThrowInternal(err, "AUTHZ-JK31F", "Errors.NotFound")
|
||||
}
|
||||
}
|
||||
return crypto.BytesToPublicKey(s.KeyData)
|
||||
}
|
||||
|
||||
func (s *systemJWTStorage) GetKeyByIDAndClientID(_ context.Context, _, userID string) (*jose.JSONWebKey, error) {
|
||||
cachedKey, ok := s.cachedKeys[userID]
|
||||
if ok {
|
||||
return &jose.JSONWebKey{KeyID: userID, Key: cachedKey}, nil
|
||||
}
|
||||
key, ok := s.keys[userID]
|
||||
if !ok {
|
||||
return nil, caos_errs.ThrowNotFound(nil, "AUTHZ-asfd3", "Errors.User.NotFound")
|
||||
}
|
||||
defer s.mutex.Unlock()
|
||||
s.mutex.Lock()
|
||||
publicKey, err := key.readKey()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.cachedKeys[userID] = publicKey
|
||||
return &jose.JSONWebKey{KeyID: userID, Key: publicKey}, nil
|
||||
}
|
||||
|
||||
type client struct {
|
||||
id string
|
||||
projectID string
|
||||
name string
|
||||
}
|
||||
|
||||
func (v *TokenVerifier) RegisterServer(appName, methodPrefix string, mappings MethodMapping) {
|
||||
v.clients.Store(methodPrefix, &client{name: appName})
|
||||
if v.authMethods == nil {
|
||||
v.authMethods = make(map[string]Option)
|
||||
}
|
||||
for method, option := range mappings {
|
||||
v.authMethods[method] = option
|
||||
}
|
||||
}
|
||||
|
||||
func (v *TokenVerifier) SearchMyMemberships(ctx context.Context, orgID string, shouldTriggerBulk bool) (_ []*Membership, err error) {
|
||||
ctx, span := tracing.NewSpan(ctx)
|
||||
defer func() { span.EndWithError(err) }()
|
||||
return v.authZRepo.SearchMyMemberships(ctx, orgID, shouldTriggerBulk)
|
||||
}
|
||||
|
||||
func (v *TokenVerifier) ProjectIDAndOriginsByClientID(ctx context.Context, clientID string) (_ string, _ []string, err error) {
|
||||
ctx, span := tracing.NewSpan(ctx)
|
||||
defer func() { span.EndWithError(err) }()
|
||||
|
||||
return v.authZRepo.ProjectIDAndOriginsByClientID(ctx, clientID)
|
||||
}
|
||||
|
||||
func (v *TokenVerifier) ExistsOrg(ctx context.Context, id, domain string) (orgID string, err error) {
|
||||
ctx, span := tracing.NewSpan(ctx)
|
||||
defer func() { span.EndWithError(err) }()
|
||||
return v.authZRepo.ExistsOrg(ctx, id, domain)
|
||||
}
|
||||
|
||||
func (v *TokenVerifier) CheckAuthMethod(method string) (Option, bool) {
|
||||
authOpt, ok := v.authMethods[method]
|
||||
return authOpt, ok
|
||||
}
|
||||
|
||||
func verifyAccessToken(ctx context.Context, token string, t *TokenVerifier, method string) (userID, clientID, agentID, prefLan, resourceOwner string, err error) {
|
||||
ctx, span := tracing.NewSpan(ctx)
|
||||
defer func() { span.EndWithError(err) }()
|
||||
|
||||
parts := strings.Split(token, BearerPrefix)
|
||||
if len(parts) != 2 {
|
||||
return "", "", "", "", "", caos_errs.ThrowUnauthenticated(nil, "AUTH-7fs1e", "invalid auth header")
|
||||
}
|
||||
return t.VerifyAccessToken(ctx, parts[1], method)
|
||||
}
|
||||
|
||||
func SessionTokenVerifier(algorithm crypto.EncryptionAlgorithm) func(ctx context.Context, sessionToken, sessionID, tokenID string) (err error) {
|
||||
return func(ctx context.Context, sessionToken, sessionID, tokenID string) (err error) {
|
||||
decodedToken, err := base64.RawURLEncoding.DecodeString(sessionToken)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, spanPasswordComparison := tracing.NewNamedSpan(ctx, "crypto.CompareHash")
|
||||
var token string
|
||||
token, err = algorithm.DecryptString(decodedToken, algorithm.EncryptionKeyID())
|
||||
spanPasswordComparison.EndWithError(err)
|
||||
if err != nil || token != fmt.Sprintf(SessionTokenFormat, sessionID, tokenID) {
|
||||
return caos_errs.ThrowPermissionDenied(err, "COMMAND-sGr42", "Errors.Session.Token.Invalid")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user