mirror of
https://github.com/andwati/zitadel.git
synced 2026-11-11 16:45:13 +00:00
Merge commit from fork
* fix: block Login V2 auth for users in deactivated organizations Enforce organization state on session creation, OIDC token issuance/refresh, and SAML session creation so deactivated org users cannot authenticate. Co-authored-by: Cursor <[email protected]> * fix: reject claim serving for users in deactivated organizations Require an active resource-owner org in OIDC userinfo and SAML attribute paths, and use distinct error IDs for inactive user vs inactive org at token issuance. Co-authored-by: Cursor <[email protected]> * fix: drop sessions and tokens when an organization is deactivated Mirror OrgRemoved cleanup for OrgDeactivated in the V2 session projection and V1 auth user_session, token, and refresh_token handlers. Also delete V2 sessions on OrgRemoved, which does not emit per-user removal events. Co-authored-by: Cursor <[email protected]> * fix: reject API tokens from deactivated organizations Check the caller's resource-owner org state in authz middleware via a cached OrgByID lookup, so already-issued tokens lose ZITADEL API access when their organization is deactivated. Return unauthenticated (401). Target org remains unrestricted so instance admins can still manage deactivated orgs. Co-authored-by: Cursor <[email protected]> * fix: invalidate OIDC refresh tokens after org deactivation Reject refresh exchange if the user's organization was deactivated after the refresh token was issued, so grants stay dead after reactivate. Co-authored-by: Cursor <[email protected]> * fix: invalidate V2 access tokens after org deactivation Treat OrgDeactivated after the token position as session termination in ActiveAccessTokenByToken, so issued ATs stay dead after reactivation. Co-authored-by: Cursor <[email protected]> --------- Co-authored-by: Cursor <[email protected]>
This commit is contained in:
@@ -20,6 +20,7 @@ type authZRepo interface {
|
||||
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)
|
||||
CheckOrgActive(ctx context.Context, orgID string) error
|
||||
}
|
||||
|
||||
var _ AccessTokenVerifier = (*AccessTokenVerifierFromRepo)(nil)
|
||||
|
||||
@@ -15,6 +15,7 @@ type APITokenVerifier interface {
|
||||
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)
|
||||
CheckOrgActive(ctx context.Context, orgID string) error
|
||||
SearchMyMemberships(ctx context.Context, orgID string, shouldTriggerBulk bool) (_ []*Membership, err error)
|
||||
}
|
||||
|
||||
@@ -63,6 +64,12 @@ func (v *ApiTokenVerifier) ExistsOrg(ctx context.Context, id, domain string) (or
|
||||
return v.authZRepo.ExistsOrg(ctx, id, domain)
|
||||
}
|
||||
|
||||
func (v *ApiTokenVerifier) CheckOrgActive(ctx context.Context, orgID string) (err error) {
|
||||
ctx, span := tracing.NewSpan(ctx)
|
||||
defer func() { span.EndWithError(err) }()
|
||||
return v.authZRepo.CheckOrgActive(ctx, orgID)
|
||||
}
|
||||
|
||||
func (v *ApiTokenVerifier) CheckAuthMethod(method string) (Option, bool) {
|
||||
authOpt, ok := v.authMethods[method]
|
||||
return authOpt, ok
|
||||
|
||||
@@ -71,6 +71,7 @@ const (
|
||||
|
||||
type TokenVerifier interface {
|
||||
ExistsOrg(ctx context.Context, id, domain string) (string, error)
|
||||
CheckOrgActive(ctx context.Context, orgID string) error
|
||||
ProjectIDAndOriginsByClientID(ctx context.Context, clientID string) (projectID string, origins []string, err error)
|
||||
AccessTokenVerifier
|
||||
SystemTokenVerifier
|
||||
@@ -132,6 +133,13 @@ func VerifyTokenAndCreateCtxData(ctx context.Context, token, orgID, orgDomain st
|
||||
return CtxData{}, zerrors.ThrowPermissionDenied(nil, "AUTH-Bs7Ds", "Organisation doesn't exist")
|
||||
}
|
||||
}
|
||||
// Reject tokens whose caller's organization is inactive. Gate resourceOwner, not the
|
||||
// resolved target orgID, so instance admins can still manage a deactivated org.
|
||||
if resourceOwner != "" {
|
||||
if err = t.CheckOrgActive(ctx, resourceOwner); err != nil {
|
||||
return CtxData{}, zerrors.ThrowUnauthenticated(err, "AUTH-oR9nA", "Errors.Org.NotActive")
|
||||
}
|
||||
}
|
||||
return CtxData{
|
||||
UserID: userID,
|
||||
OrgID: orgID,
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
package authz
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/zitadel/zitadel/internal/zerrors"
|
||||
)
|
||||
|
||||
type tokenVerifierMock struct {
|
||||
AccessTokenVerifier
|
||||
SystemTokenVerifier
|
||||
existsOrg func(ctx context.Context, id, domain string) (string, error)
|
||||
checkOrgActive func(ctx context.Context, orgID string) error
|
||||
projectIDOrigin func(ctx context.Context, clientID string) (string, []string, error)
|
||||
}
|
||||
|
||||
func (m *tokenVerifierMock) RegisterServer(string, string, MethodMapping) {}
|
||||
|
||||
func (m *tokenVerifierMock) CheckAuthMethod(string) (Option, bool) {
|
||||
return Option{}, false
|
||||
}
|
||||
|
||||
func (m *tokenVerifierMock) SearchMyMemberships(context.Context, string, bool) ([]*Membership, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *tokenVerifierMock) ExistsOrg(ctx context.Context, id, domain string) (string, error) {
|
||||
if m.existsOrg != nil {
|
||||
return m.existsOrg(ctx, id, domain)
|
||||
}
|
||||
if id != "" {
|
||||
return id, nil
|
||||
}
|
||||
return domain, nil
|
||||
}
|
||||
|
||||
func (m *tokenVerifierMock) CheckOrgActive(ctx context.Context, orgID string) error {
|
||||
if m.checkOrgActive != nil {
|
||||
return m.checkOrgActive(ctx, orgID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *tokenVerifierMock) ProjectIDAndOriginsByClientID(ctx context.Context, clientID string) (string, []string, error) {
|
||||
if m.projectIDOrigin != nil {
|
||||
return m.projectIDOrigin(ctx, clientID)
|
||||
}
|
||||
return "", nil, nil
|
||||
}
|
||||
|
||||
func TestVerifyTokenAndCreateCtxData_orgActive(t *testing.T) {
|
||||
activeCaller := AccessTokenVerifierFunc(func(context.Context, string) (string, string, string, string, string, error) {
|
||||
return "user1", "", "", "", "caller-org", nil
|
||||
})
|
||||
systemTokenNOK := SystemTokenVerifierFunc(func(context.Context, string, string) (Memberships, string, error) {
|
||||
return nil, "", zerrors.ThrowUnauthenticated(nil, "TEST-sys", "unauthenticated")
|
||||
})
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
orgID string
|
||||
orgDomain string
|
||||
accessToken AccessTokenVerifier
|
||||
checkOrgActive func(context.Context, string) error
|
||||
wantErr func(error) bool
|
||||
wantResource string
|
||||
wantOrgID string
|
||||
}{
|
||||
{
|
||||
name: "active caller org",
|
||||
accessToken: activeCaller,
|
||||
wantResource: "caller-org",
|
||||
wantOrgID: "caller-org",
|
||||
},
|
||||
{
|
||||
name: "deactivated caller org",
|
||||
accessToken: activeCaller,
|
||||
checkOrgActive: func(_ context.Context, orgID string) error {
|
||||
assert.Equal(t, "caller-org", orgID)
|
||||
return zerrors.ThrowPreconditionFailed(nil, "QUERY-oR9nA", "Errors.Org.NotActive")
|
||||
},
|
||||
wantErr: zerrors.IsUnauthenticated,
|
||||
},
|
||||
{
|
||||
name: "deactivated target org with active caller",
|
||||
orgID: "target-org",
|
||||
accessToken: activeCaller,
|
||||
checkOrgActive: func(_ context.Context, orgID string) error {
|
||||
// Must check the caller's org, not the target.
|
||||
assert.Equal(t, "caller-org", orgID)
|
||||
return nil
|
||||
},
|
||||
wantResource: "caller-org",
|
||||
wantOrgID: "target-org",
|
||||
},
|
||||
{
|
||||
name: "empty resource owner for system token",
|
||||
accessToken: AccessTokenVerifierFunc(func(context.Context, string) (string, string, string, string, string, error) {
|
||||
return "", "", "", "", "", zerrors.ThrowUnauthenticated(nil, "TEST-tok", "unauthenticated")
|
||||
}),
|
||||
orgID: "system-org",
|
||||
checkOrgActive: func(context.Context, string) error {
|
||||
t.Fatal("CheckOrgActive must not be called for system tokens without resource owner")
|
||||
return nil
|
||||
},
|
||||
wantOrgID: "system-org",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
verifier := &tokenVerifierMock{
|
||||
AccessTokenVerifier: tt.accessToken,
|
||||
SystemTokenVerifier: SystemTokenVerifierFunc(func(ctx context.Context, token, orgID string) (Memberships, string, error) {
|
||||
if tt.name == "empty resource owner for system token" {
|
||||
return Memberships{{MemberType: MemberTypeSystem, Roles: []string{"SYSTEM_OWNER"}}}, "system-user", nil
|
||||
}
|
||||
return systemTokenNOK.VerifySystemToken(ctx, token, orgID)
|
||||
}),
|
||||
checkOrgActive: tt.checkOrgActive,
|
||||
}
|
||||
got, err := VerifyTokenAndCreateCtxData(context.Background(), BearerPrefix+"token", tt.orgID, tt.orgDomain, verifier, nil)
|
||||
if tt.wantErr != nil {
|
||||
require.Error(t, err)
|
||||
assert.True(t, tt.wantErr(err))
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tt.wantResource, got.ResourceOwner)
|
||||
assert.Equal(t, tt.wantOrgID, got.OrgID)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
//go:build integration
|
||||
|
||||
package org_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/zitadel/zitadel/internal/integration"
|
||||
"github.com/zitadel/zitadel/pkg/grpc/auth"
|
||||
"github.com/zitadel/zitadel/pkg/grpc/org/v2"
|
||||
)
|
||||
|
||||
func TestServer_DeactivateOrganization_RejectsCallerToken(t *testing.T) {
|
||||
orgResp := Instance.CreateOrganization(CTX, integration.OrganizationName(), integration.Email())
|
||||
userID := orgResp.GetCreatedAdmins()[0].GetUserId()
|
||||
pat := Instance.CreatePersonalAccessToken(CTX, userID)
|
||||
userCtx := integration.WithAuthorizationToken(CTX, pat.GetToken())
|
||||
|
||||
_, err := Instance.Client.Auth.GetMyUser(userCtx, &auth.GetMyUserRequest{})
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = Client.DeactivateOrganization(CTX, &org.DeactivateOrganizationRequest{
|
||||
OrganizationId: orgResp.GetOrganizationId(),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
retryDuration, tick := integration.WaitForAndTickWithMaxDuration(CTX, time.Minute)
|
||||
require.EventuallyWithT(t, func(collect *assert.CollectT) {
|
||||
_, getErr := Instance.Client.Auth.GetMyUser(userCtx, &auth.GetMyUserRequest{})
|
||||
assert.Error(collect, getErr)
|
||||
}, retryDuration, tick, "timeout waiting for deactivated org to reject caller token")
|
||||
|
||||
// Instance admin can still target the deactivated org.
|
||||
_, err = Client.ListOrganizations(CTX, &org.ListOrganizationsRequest{
|
||||
Queries: []*org.SearchQuery{
|
||||
{
|
||||
Query: &org.SearchQuery_IdQuery{
|
||||
IdQuery: &org.OrganizationIDQuery{
|
||||
Id: orgResp.GetOrganizationId(),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
}
|
||||
@@ -37,6 +37,10 @@ func (v *authzRepoMock) ExistsOrg(ctx context.Context, orgID, domain string) (st
|
||||
return orgID, nil
|
||||
}
|
||||
|
||||
func (v *authzRepoMock) CheckOrgActive(ctx context.Context, orgID string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (v *authzRepoMock) VerifierClientID(ctx context.Context, appName string) (string, string, error) {
|
||||
return "", "", nil
|
||||
}
|
||||
|
||||
@@ -37,6 +37,10 @@ func (v *authzRepoMock) ExistsOrg(ctx context.Context, orgID, domain string) (st
|
||||
return orgID, nil
|
||||
}
|
||||
|
||||
func (v *authzRepoMock) CheckOrgActive(ctx context.Context, orgID string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (v *authzRepoMock) VerifierClientID(ctx context.Context, appName string) (string, string, error) {
|
||||
return "", "", nil
|
||||
}
|
||||
|
||||
@@ -126,6 +126,13 @@ func (s *Server) checksToCommand(ctx context.Context, checks *session.Checks) ([
|
||||
if !user.State.IsEnabled() {
|
||||
return nil, zerrors.ThrowPreconditionFailed(nil, "SESSION-Gj4ko", "Errors.User.NotActive")
|
||||
}
|
||||
org, err := s.query.OrgByID(ctx, user.ResourceOwner)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !org.State.IsActive() {
|
||||
return nil, zerrors.ThrowPreconditionFailed(nil, "SESSION-oL9nT", "Errors.User.NotActive")
|
||||
}
|
||||
|
||||
var preferredLanguage *language.Tag
|
||||
if user.Human != nil && !user.Human.PreferredLanguage.IsRoot() {
|
||||
|
||||
@@ -347,6 +347,13 @@ func (s *Server) checksToCommand(ctx context.Context, checks *session.Checks) ([
|
||||
if !user.State.IsEnabled() {
|
||||
return nil, zerrors.ThrowPreconditionFailed(nil, "SESSION-Gj4ko", "Errors.User.NotActive")
|
||||
}
|
||||
org, err := s.query.OrgByID(ctx, user.ResourceOwner)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !org.State.IsActive() {
|
||||
return nil, zerrors.ThrowPreconditionFailed(nil, "SESSION-oL9nT", "Errors.User.NotActive")
|
||||
}
|
||||
|
||||
var preferredLanguage *language.Tag
|
||||
if user.Human != nil && !user.Human.PreferredLanguage.IsRoot() {
|
||||
|
||||
@@ -210,6 +210,13 @@ func (p *Storage) SetUserinfoWithUserID(ctx context.Context, applicationID strin
|
||||
if user.State != domain.UserStateActive {
|
||||
return zerrors.ThrowPreconditionFailed(nil, "SAML-S3gFd", "Errors.User.NotActive")
|
||||
}
|
||||
org, err := p.query.OrgByID(ctx, user.ResourceOwner)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !org.State.IsActive() {
|
||||
return zerrors.ThrowPreconditionFailed(nil, "SAML-oR9nA", "Errors.User.NotActive")
|
||||
}
|
||||
|
||||
userGrants, err := p.getGrants(ctx, userID, applicationID)
|
||||
if err != nil {
|
||||
@@ -239,6 +246,13 @@ func (p *Storage) SetUserinfoWithLoginName(ctx context.Context, userinfo models.
|
||||
if user.State != domain.UserStateActive {
|
||||
return zerrors.ThrowPreconditionFailed(nil, "SAML-FJ262", "Errors.User.NotActive")
|
||||
}
|
||||
org, err := p.query.OrgByID(ctx, user.ResourceOwner)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !org.State.IsActive() {
|
||||
return zerrors.ThrowPreconditionFailed(nil, "SAML-pQ2mB", "Errors.User.NotActive")
|
||||
}
|
||||
|
||||
setUserinfo(user, userinfo, attributes, map[string]*customAttribute{})
|
||||
return nil
|
||||
|
||||
Reference in New Issue
Block a user