diff --git a/internal/api/authz/access_token.go b/internal/api/authz/access_token.go index de7634c36..a071e8637 100644 --- a/internal/api/authz/access_token.go +++ b/internal/api/authz/access_token.go @@ -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) diff --git a/internal/api/authz/api_token_verifier.go b/internal/api/authz/api_token_verifier.go index 4e25a30aa..f60a9a451 100644 --- a/internal/api/authz/api_token_verifier.go +++ b/internal/api/authz/api_token_verifier.go @@ -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 diff --git a/internal/api/authz/context.go b/internal/api/authz/context.go index d068b6987..fe3cb80c9 100644 --- a/internal/api/authz/context.go +++ b/internal/api/authz/context.go @@ -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, diff --git a/internal/api/authz/context_test.go b/internal/api/authz/context_test.go new file mode 100644 index 000000000..e35273a64 --- /dev/null +++ b/internal/api/authz/context_test.go @@ -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) + }) + } +} diff --git a/internal/api/grpc/org/v2/integration_test/org_deactivate_token_test.go b/internal/api/grpc/org/v2/integration_test/org_deactivate_token_test.go new file mode 100644 index 000000000..a7bf2c300 --- /dev/null +++ b/internal/api/grpc/org/v2/integration_test/org_deactivate_token_test.go @@ -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) +} diff --git a/internal/api/grpc/server/connect_middleware/auth_interceptor_test.go b/internal/api/grpc/server/connect_middleware/auth_interceptor_test.go index 06e716c14..7cbfd411d 100644 --- a/internal/api/grpc/server/connect_middleware/auth_interceptor_test.go +++ b/internal/api/grpc/server/connect_middleware/auth_interceptor_test.go @@ -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 } diff --git a/internal/api/grpc/server/middleware/auth_interceptor_test.go b/internal/api/grpc/server/middleware/auth_interceptor_test.go index e09818944..a65c5ac5b 100644 --- a/internal/api/grpc/server/middleware/auth_interceptor_test.go +++ b/internal/api/grpc/server/middleware/auth_interceptor_test.go @@ -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 } diff --git a/internal/api/grpc/session/v2/session.go b/internal/api/grpc/session/v2/session.go index abd0478aa..6f3430973 100644 --- a/internal/api/grpc/session/v2/session.go +++ b/internal/api/grpc/session/v2/session.go @@ -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() { diff --git a/internal/api/grpc/session/v2beta/session.go b/internal/api/grpc/session/v2beta/session.go index e7889cf5b..d10eeab7d 100644 --- a/internal/api/grpc/session/v2beta/session.go +++ b/internal/api/grpc/session/v2beta/session.go @@ -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() { diff --git a/internal/api/saml/storage.go b/internal/api/saml/storage.go index b20d8ba3e..f8b45b626 100644 --- a/internal/api/saml/storage.go +++ b/internal/api/saml/storage.go @@ -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 diff --git a/internal/auth/repository/eventsourcing/handler/refresh_token.go b/internal/auth/repository/eventsourcing/handler/refresh_token.go index c63f58123..ad0aecb3f 100644 --- a/internal/auth/repository/eventsourcing/handler/refresh_token.go +++ b/internal/auth/repository/eventsourcing/handler/refresh_token.go @@ -90,6 +90,10 @@ func (t *RefreshToken) Reducers() []handler.AggregateReducer { Event: org.OrgRemovedEventType, Reduce: t.Reduce, }, + { + Event: org.OrgDeactivatedEventType, + Reduce: t.Reduce, + }, }, }, } @@ -166,7 +170,8 @@ func (t *RefreshToken) Reduce(event eventstore.Event) (_ *handler.Statement, err handler.NewCond(view_model.RefreshTokenKeyInstanceID, event.Aggregate().InstanceID), }, ), nil - case org.OrgRemovedEventType: + case org.OrgRemovedEventType, + org.OrgDeactivatedEventType: return handler.NewDeleteStatement(event, []handler.Condition{ handler.NewCond(view_model.RefreshTokenKeyInstanceID, event.Aggregate().InstanceID), diff --git a/internal/auth/repository/eventsourcing/handler/token.go b/internal/auth/repository/eventsourcing/handler/token.go index d915037cb..fbda371a5 100644 --- a/internal/auth/repository/eventsourcing/handler/token.go +++ b/internal/auth/repository/eventsourcing/handler/token.go @@ -136,6 +136,10 @@ func (t *Token) Reducers() []handler.AggregateReducer { Event: org.OrgRemovedEventType, Reduce: t.Reduce, }, + { + Event: org.OrgDeactivatedEventType, + Reduce: t.Reduce, + }, }, }, { @@ -311,7 +315,8 @@ func (t *Token) Reduce(event eventstore.Event) (_ *handler.Statement, err error) handler.NewCond(view_model.TokenKeyInstanceID, event.Aggregate().InstanceID), }, ), nil - case org.OrgRemovedEventType: + case org.OrgRemovedEventType, + org.OrgDeactivatedEventType: return handler.NewDeleteStatement(event, []handler.Condition{ handler.NewCond(view_model.TokenKeyInstanceID, event.Aggregate().InstanceID), diff --git a/internal/auth/repository/eventsourcing/handler/user_session.go b/internal/auth/repository/eventsourcing/handler/user_session.go index a4de4fdc3..aa33cae7b 100644 --- a/internal/auth/repository/eventsourcing/handler/user_session.go +++ b/internal/auth/repository/eventsourcing/handler/user_session.go @@ -192,6 +192,10 @@ func (s *UserSession) Reducers() []handler.AggregateReducer { Event: org.OrgRemovedEventType, Reduce: s.Reduce, }, + { + Event: org.OrgDeactivatedEventType, + Reduce: s.Reduce, + }, }, }, { @@ -502,7 +506,8 @@ func (u *UserSession) Reduce(event eventstore.Event) (_ *handler.Statement, err handler.NewCond(view_model.UserSessionKeyInstanceID, event.Aggregate().InstanceID), }, ), nil - case org.OrgRemovedEventType: + case org.OrgRemovedEventType, + org.OrgDeactivatedEventType: return handler.NewDeleteStatement(event, []handler.Condition{ handler.NewCond(view_model.UserSessionKeyInstanceID, event.Aggregate().InstanceID), diff --git a/internal/command/device_auth_test.go b/internal/command/device_auth_test.go index 11d5630b0..fbf4749cc 100644 --- a/internal/command/device_auth_test.go +++ b/internal/command/device_auth_test.go @@ -1247,6 +1247,7 @@ func TestCommands_CreateOIDCSessionFromDeviceAuth(t *testing.T) { false, ), ), + expectFilterActiveOrg("org1"), expectFilter(), // token lifetime expectPush( oidcsession.NewAddedEvent(context.Background(), &oidcsession.NewAggregate("V2_oidcSessionID", "org1").Aggregate, @@ -1345,6 +1346,7 @@ func TestCommands_CreateOIDCSessionFromDeviceAuth(t *testing.T) { false, ), ), + expectFilterActiveOrg("org1"), expectFilter(), // token lifetime expectPush( oidcsession.NewAddedEvent(context.Background(), &oidcsession.NewAggregate("V2_oidcSessionID", "org1").Aggregate, @@ -1450,6 +1452,7 @@ func TestCommands_CreateOIDCSessionFromDeviceAuth(t *testing.T) { false, ), ), + expectFilterActiveOrg("org1"), expectFilter(), // token lifetime expectPush( oidcsession.NewAddedEvent(context.Background(), &oidcsession.NewAggregate("V2_oidcSessionID", "org1").Aggregate, diff --git a/internal/command/main_test.go b/internal/command/main_test.go index 1cee2a8fa..841ce23be 100644 --- a/internal/command/main_test.go +++ b/internal/command/main_test.go @@ -19,6 +19,7 @@ import ( "github.com/zitadel/zitadel/internal/eventstore/repository/mock" "github.com/zitadel/zitadel/internal/execution/target" "github.com/zitadel/zitadel/internal/feature" + "github.com/zitadel/zitadel/internal/repository/org" "github.com/zitadel/zitadel/internal/zerrors" ) @@ -104,6 +105,15 @@ func expectFilter(events ...eventstore.Event) expect { m.ExpectFilterEvents(events...) } } + +func expectFilterActiveOrg(orgID string) expect { + return expectFilter( + org.NewOrgAddedEvent(context.Background(), + &org.NewAggregate(orgID).Aggregate, + "org"), + ) +} + func expectFilterError(err error) expect { return func(m *mock.MockRepository) { m.ExpectFilterEventsError(err) diff --git a/internal/command/oidc_session.go b/internal/command/oidc_session.go index b52e1a21c..37151d79c 100644 --- a/internal/command/oidc_session.go +++ b/internal/command/oidc_session.go @@ -241,6 +241,9 @@ func (c *Commands) OIDCSessionByRefreshToken(ctx context.Context, refreshToken s if err = writeModel.CheckRefreshToken(refreshTokenID); err != nil { return nil, err } + if err = c.checkOrgNotDeactivatedAfter(ctx, writeModel.UserResourceOwner, writeModel.RefreshTokenIssuedAt); err != nil { + return nil, err + } return writeModel, nil } @@ -294,13 +297,10 @@ func (c *Commands) RevokeOIDCSessionToken(ctx context.Context, token, clientID s } func (c *Commands) newOIDCSessionAddEvents(ctx context.Context, userID, resourceOwner string, pending ...eventstore.Command) (*OIDCSessionEvents, error) { - userStateModel, err := c.userStateWriteModel(ctx, userID) + userStateModel, err := c.userStateForAuthentication(ctx, userID, resourceOwner, "OIDCS-kj3g2", "OIDCS-oR9nA") if err != nil { return nil, err } - if !userStateModel.UserState.IsEnabled() { - return nil, zerrors.ThrowPreconditionFailed(nil, "OIDCS-kj3g2", "Errors.User.NotActive") - } accessTokenLifetime, refreshTokenLifeTime, refreshTokenIdleLifetime, err := c.tokenTokenLifetimes(ctx) if err != nil { return nil, err @@ -353,12 +353,11 @@ func (c *Commands) newOIDCSessionUpdateEvents(ctx context.Context, refreshToken if err = sessionWriteModel.CheckRefreshToken(refreshTokenID); err != nil { return nil, err } - userStateWriteModel, err := c.userStateWriteModel(ctx, sessionWriteModel.UserID) - if err != nil { + if err = c.checkOrgNotDeactivatedAfter(ctx, sessionWriteModel.UserResourceOwner, sessionWriteModel.RefreshTokenIssuedAt); err != nil { return nil, err } - if !userStateWriteModel.UserState.IsEnabled() { - return nil, zerrors.ThrowPreconditionFailed(nil, "OIDCS-J39h2", "Errors.User.NotActive") + if _, err = c.userStateForAuthentication(ctx, sessionWriteModel.UserID, sessionWriteModel.UserResourceOwner, "OIDCS-J39h2", "OIDCS-pQ2mB"); err != nil { + return nil, err } accessTokenLifetime, refreshTokenLifeTime, refreshTokenIdleLifetime, err := c.tokenTokenLifetimes(ctx) if err != nil { diff --git a/internal/command/oidc_session_model.go b/internal/command/oidc_session_model.go index 9468f95c4..cca11787b 100644 --- a/internal/command/oidc_session_model.go +++ b/internal/command/oidc_session_model.go @@ -35,6 +35,7 @@ type OIDCSessionWriteModel struct { RefreshToken string RefreshTokenExpiration time.Time RefreshTokenIdleExpiration time.Time + RefreshTokenIssuedAt time.Time aggregate *eventstore.Aggregate } @@ -125,6 +126,7 @@ func (wm *OIDCSessionWriteModel) reduceRefreshTokenAdded(e *oidcsession.RefreshT wm.RefreshTokenID = e.ID wm.RefreshTokenExpiration = e.CreationDate().Add(e.Lifetime) wm.RefreshTokenIdleExpiration = e.CreationDate().Add(e.IdleLifetime) + wm.RefreshTokenIssuedAt = e.CreationDate() } func (wm *OIDCSessionWriteModel) reduceRefreshTokenRenewed(e *oidcsession.RefreshTokenRenewedEvent) { @@ -136,6 +138,7 @@ func (wm *OIDCSessionWriteModel) reduceRefreshTokenRevoked(e *oidcsession.Refres wm.RefreshTokenID = "" wm.RefreshTokenExpiration = e.CreationDate() wm.RefreshTokenIdleExpiration = e.CreationDate() + wm.RefreshTokenIssuedAt = time.Time{} wm.AccessTokenID = "" wm.AccessTokenExpiration = e.CreationDate() } diff --git a/internal/command/oidc_session_test.go b/internal/command/oidc_session_test.go index fe2db4446..3c1cf3042 100644 --- a/internal/command/oidc_session_test.go +++ b/internal/command/oidc_session_test.go @@ -22,6 +22,7 @@ import ( "github.com/zitadel/zitadel/internal/id/mock" "github.com/zitadel/zitadel/internal/repository/authrequest" "github.com/zitadel/zitadel/internal/repository/oidcsession" + "github.com/zitadel/zitadel/internal/repository/org" "github.com/zitadel/zitadel/internal/repository/session" "github.com/zitadel/zitadel/internal/repository/sessionlogout" "github.com/zitadel/zitadel/internal/repository/user" @@ -388,6 +389,7 @@ func TestCommands_CreateOIDCSessionFromAuthRequest(t *testing.T) { false, ), ), + expectFilterActiveOrg("org1"), expectFilter(), // token lifetime expectPush( authrequest.NewCodeExchangedEvent(context.Background(), &authrequest.NewAggregate("V2_authRequestID", "instanceID").Aggregate), @@ -523,6 +525,7 @@ func TestCommands_CreateOIDCSessionFromAuthRequest(t *testing.T) { false, ), ), + expectFilterActiveOrg("org1"), expectFilter(), // token lifetime expectPush( authrequest.NewCodeExchangedEvent(context.Background(), &authrequest.NewAggregate("V2_authRequestID", "instanceID").Aggregate), @@ -664,6 +667,7 @@ func TestCommands_CreateOIDCSessionFromAuthRequest(t *testing.T) { false, ), ), + expectFilterActiveOrg("org1"), expectFilter(), // token lifetime expectPush( oidcsession.NewAddedEvent(context.Background(), &oidcsession.NewAggregate("V2_oidcSessionID", "org1").Aggregate, @@ -885,6 +889,7 @@ func TestCommands_CreateOIDCSession(t *testing.T) { false, ), ), + expectFilterActiveOrg("org1"), expectFilter(), // token lifetime expectPush( oidcsession.NewAddedEvent(context.Background(), &oidcsession.NewAggregate("V2_oidcSessionID", "org1").Aggregate, @@ -981,6 +986,7 @@ func TestCommands_CreateOIDCSession(t *testing.T) { false, ), ), + expectFilterActiveOrg("org1"), expectFilter(), // token lifetime expectPush( oidcsession.NewAddedEvent(context.Background(), &oidcsession.NewAggregate("V2_oidcSessionID", "org1").Aggregate, @@ -1062,6 +1068,7 @@ func TestCommands_CreateOIDCSession(t *testing.T) { false, ), ), + expectFilterActiveOrg("org1"), expectFilter(), // token lifetime expectPush( oidcsession.NewAddedEvent(context.Background(), &oidcsession.NewAggregate("V2_oidcSessionID", "org1").Aggregate, @@ -1160,6 +1167,7 @@ func TestCommands_CreateOIDCSession(t *testing.T) { false, ), ), + expectFilterActiveOrg("org1"), expectFilter(), // token lifetime expectPush( oidcsession.NewAddedEvent(context.Background(), &oidcsession.NewAggregate("V2_oidcSessionID", "org1").Aggregate, @@ -1258,6 +1266,7 @@ func TestCommands_CreateOIDCSession(t *testing.T) { false, ), ), + expectFilterActiveOrg("org1"), expectFilter(), // token lifetime expectPush( oidcsession.NewAddedEvent(context.Background(), &oidcsession.NewAggregate("V2_oidcSessionID", "org1").Aggregate, @@ -1354,6 +1363,7 @@ func TestCommands_CreateOIDCSession(t *testing.T) { false, ), ), + expectFilterActiveOrg("org1"), expectFilter(), // token lifetime expectPush( oidcsession.NewAddedEvent(context.Background(), &oidcsession.NewAggregate("V2_oidcSessionID", "org1").Aggregate, @@ -1459,6 +1469,7 @@ func TestCommands_CreateOIDCSession(t *testing.T) { false, ), ), + expectFilterActiveOrg("org1"), expectFilter(), // token lifetime ), idGenerator: mock.NewIDGeneratorExpectIDs(t, "oidcSessionID"), @@ -1516,6 +1527,7 @@ func TestCommands_CreateOIDCSession(t *testing.T) { false, ), ), + expectFilterActiveOrg("org1"), expectFilter(), // token lifetime expectPush( user.NewUserImpersonatedEvent(context.Background(), &user.NewAggregate("userID", "org1").Aggregate, "clientID", &domain.TokenActor{ @@ -1799,6 +1811,7 @@ func TestCommands_ExchangeOIDCSessionRefreshAndAccessToken(t *testing.T) { "rt_refreshTokenID", 7*24*time.Hour, 24*time.Hour), ), ), + expectFilter(), // no OrgDeactivated after refresh token issuance expectFilter( user.NewHumanAddedEvent( context.Background(), @@ -1835,6 +1848,48 @@ func TestCommands_ExchangeOIDCSessionRefreshAndAccessToken(t *testing.T) { err: zerrors.ThrowPreconditionFailed(nil, "OIDCS-J39h2", "Errors.User.NotActive"), }, }, + { + "org deactivated after refresh token issuance", + fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusherWithCreationDateNow( + oidcsession.NewAddedEvent(context.Background(), &oidcsession.NewAggregate("V2_oidcSessionID", "org1").Aggregate, + "userID", "org1", "sessionID", "clientID", []string{"audience"}, []string{"openid", "profile", "offline_access"}, + []domain.UserAuthMethodType{domain.UserAuthMethodTypePassword}, testNow, "nonce", &language.Afrikaans, + &domain.UserAgent{FingerprintID: gu.Ptr("browserFP")}, + ), + ), + eventFromEventPusherWithCreationDateNow( + oidcsession.NewAccessTokenAddedEvent(context.Background(), &oidcsession.NewAggregate("V2_oidcSessionID", "org1").Aggregate, + "at_accessTokenID", []string{"openid", "profile", "offline_access"}, time.Hour, domain.TokenReasonAuthRequest, nil), + ), + eventFromEventPusherWithCreationDate( + oidcsession.NewRefreshTokenAddedEvent(context.Background(), &oidcsession.NewAggregate("V2_oidcSessionID", "org1").Aggregate, + "rt_refreshTokenID", 7*24*time.Hour, 24*time.Hour), + testNow, + ), + ), + expectFilter( + eventFromEventPusherWithCreationDate( + org.NewOrgDeactivatedEvent(context.Background(), + &org.NewAggregate("org1").Aggregate), + testNow.Add(time.Minute), + ), + ), + ), + keyAlgorithm: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), + }, + args{ + ctx: authz.WithInstanceID(context.Background(), "instanceID"), + refreshToken: "V2_oidcSessionID-rt_refreshTokenID:userID", + scope: []string{"openid", "offline_access"}, + complianceCheck: mockRefreshTokenComplianceChecker(nil), + }, + res{ + err: zerrors.ThrowPreconditionFailed(nil, "OIDCS-oR9nR", "Errors.OIDCSession.RefreshTokenInvalid"), + }, + }, { "refresh with an invalid client id fails", fields{ @@ -1856,6 +1911,7 @@ func TestCommands_ExchangeOIDCSessionRefreshAndAccessToken(t *testing.T) { "rt_refreshTokenID", 7*24*time.Hour, 24*time.Hour), ), ), + expectFilter(), // no OrgDeactivated after refresh token issuance expectFilter( user.NewHumanAddedEvent( context.Background(), @@ -1871,6 +1927,7 @@ func TestCommands_ExchangeOIDCSessionRefreshAndAccessToken(t *testing.T) { false, ), ), + expectFilterActiveOrg("org1"), expectFilter(), ), idGenerator: mock.NewIDGeneratorExpectIDs(t), @@ -1908,6 +1965,7 @@ func TestCommands_ExchangeOIDCSessionRefreshAndAccessToken(t *testing.T) { "rt_refreshTokenID", 7*24*time.Hour, 24*time.Hour), ), ), + expectFilter(), // no OrgDeactivated after refresh token issuance expectFilter( user.NewHumanAddedEvent( context.Background(), @@ -1923,6 +1981,7 @@ func TestCommands_ExchangeOIDCSessionRefreshAndAccessToken(t *testing.T) { false, ), ), + expectFilterActiveOrg("org1"), expectFilter(), // token lifetime expectPush( oidcsession.NewAccessTokenAddedEvent(context.Background(), &oidcsession.NewAggregate("V2_oidcSessionID", "org1").Aggregate, @@ -2121,6 +2180,7 @@ func TestCommands_OIDCSessionByRefreshToken(t *testing.T) { "rt_refreshTokenID", 7*24*time.Hour, 24*time.Hour), ), ), + expectFilter(), // no OrgDeactivated after refresh token issuance ), keyAlgorithm: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), }, diff --git a/internal/command/org_auth.go b/internal/command/org_auth.go new file mode 100644 index 000000000..3abee43b4 --- /dev/null +++ b/internal/command/org_auth.go @@ -0,0 +1,60 @@ +package command + +import ( + "context" + "time" + + "github.com/zitadel/zitadel/internal/eventstore" + "github.com/zitadel/zitadel/internal/repository/org" + "github.com/zitadel/zitadel/internal/telemetry/tracing" + "github.com/zitadel/zitadel/internal/zerrors" +) + +// checkOrgNotDeactivatedAfter returns an invalid refresh token error if the organization +// was deactivated after the given time. This keeps refresh grants dead after reactivation. +func (c *Commands) checkOrgNotDeactivatedAfter(ctx context.Context, orgID string, after time.Time) (err error) { + ctx, span := tracing.NewSpan(ctx) + defer func() { span.EndWithError(err) }() + + if orgID == "" || after.IsZero() { + return nil + } + model := &orgDeactivatedAfterModel{ + orgID: orgID, + after: after, + } + if err = c.eventstore.FilterToQueryReducer(ctx, model); err != nil { + return zerrors.ThrowPreconditionFailed(err, "OIDCS-oR9nD", "Errors.Internal") + } + if model.deactivated { + return zerrors.ThrowPreconditionFailed(nil, "OIDCS-oR9nR", "Errors.OIDCSession.RefreshTokenInvalid") + } + return nil +} + +type orgDeactivatedAfterModel struct { + orgID string + after time.Time + + events int + deactivated bool +} + +func (m *orgDeactivatedAfterModel) Reduce() error { + m.deactivated = m.events > 0 + return nil +} + +func (m *orgDeactivatedAfterModel) AppendEvents(events ...eventstore.Event) { + m.events += len(events) +} + +func (m *orgDeactivatedAfterModel) Query() *eventstore.SearchQueryBuilder { + return eventstore.NewSearchQueryBuilder(eventstore.ColumnsEvent). + CreationDateAfter(m.after). + AddQuery(). + AggregateTypes(org.AggregateType). + AggregateIDs(m.orgID). + EventTypes(org.OrgDeactivatedEventType). + Builder() +} diff --git a/internal/command/saml_session.go b/internal/command/saml_session.go index 6329f35c5..5f5648c48 100644 --- a/internal/command/saml_session.go +++ b/internal/command/saml_session.go @@ -93,13 +93,10 @@ func (c *Commands) CreateSAMLSessionFromSAMLRequest(ctx context.Context, samlReq } func (c *Commands) newSAMLSessionAddEvents(ctx context.Context, userID, resourceOwner string, pending ...eventstore.Command) (*SAMLSessionEvents, error) { - userStateModel, err := c.userStateWriteModel(ctx, userID) + userStateModel, err := c.userStateForAuthentication(ctx, userID, resourceOwner, "SAML-1768ZQpmcP", "SAML-oR9nA") if err != nil { return nil, err } - if !userStateModel.UserState.IsEnabled() { - return nil, zerrors.ThrowPreconditionFailed(nil, "SAML-1768ZQpmcP", "Errors.User.NotActive") - } sessionID, err := c.idGenerator.Next() if err != nil { return nil, err diff --git a/internal/command/saml_session_test.go b/internal/command/saml_session_test.go index 15445e9e5..095d77043 100644 --- a/internal/command/saml_session_test.go +++ b/internal/command/saml_session_test.go @@ -299,6 +299,7 @@ func TestCommands_CreateSAMLSessionFromSAMLRequest(t *testing.T) { false, ), ), + expectFilterActiveOrg("org1"), expectPush( samlsession.NewAddedEvent(context.Background(), &samlsession.NewAggregate("V2_samlSessionID", "org1").Aggregate, "userID", "org1", "sessionID", "issuer", []string{"issuer"}, diff --git a/internal/command/user_auth.go b/internal/command/user_auth.go new file mode 100644 index 000000000..45af19798 --- /dev/null +++ b/internal/command/user_auth.go @@ -0,0 +1,39 @@ +package command + +import ( + "context" + + "github.com/zitadel/zitadel/internal/telemetry/tracing" + "github.com/zitadel/zitadel/internal/zerrors" +) + +func (c *Commands) userStateForAuthentication(ctx context.Context, userID, resourceOwner, userErrorID, orgErrorID string) (_ *UserV2WriteModel, err error) { + ctx, span := tracing.NewSpan(ctx) + defer func() { span.EndWithError(err) }() + + userStateModel, err := c.userStateWriteModel(ctx, userID) + if err != nil { + return nil, err + } + if !userStateModel.UserState.IsEnabled() { + return nil, zerrors.ThrowPreconditionFailed(nil, userErrorID, "Errors.User.NotActive") + } + if err := c.checkOrgActiveForAuthentication(ctx, resourceOwner, orgErrorID); err != nil { + return nil, err + } + return userStateModel, nil +} + +func (c *Commands) checkOrgActiveForAuthentication(ctx context.Context, orgID, errorID string) (err error) { + ctx, span := tracing.NewSpan(ctx) + defer func() { span.EndWithError(err) }() + + orgWriteModel, err := c.getOrgWriteModelByID(ctx, orgID) + if err != nil { + return err + } + if !orgWriteModel.State.IsActive() { + return zerrors.ThrowPreconditionFailed(nil, errorID, "Errors.User.NotActive") + } + return nil +} diff --git a/internal/command/user_auth_test.go b/internal/command/user_auth_test.go new file mode 100644 index 000000000..0c9430995 --- /dev/null +++ b/internal/command/user_auth_test.go @@ -0,0 +1,148 @@ +package command + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" + "golang.org/x/text/language" + + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/eventstore" + "github.com/zitadel/zitadel/internal/repository/org" + "github.com/zitadel/zitadel/internal/repository/user" + "github.com/zitadel/zitadel/internal/zerrors" +) + +func TestCommands_userStateForAuthentication(t *testing.T) { + type args struct { + ctx context.Context + userID string + resourceOwner string + userErrorID string + orgErrorID string + } + tests := []struct { + name string + eventstore func(*testing.T) *eventstore.Eventstore + args args + wantErr func(error) bool + }{ + { + name: "user not active", + eventstore: expectEventstore( + expectFilter( + user.NewHumanAddedEvent( + context.Background(), + &user.NewAggregate("userID", "org1").Aggregate, + "username", + "firstname", + "lastname", + "nickname", + "displayname", + language.English, + domain.GenderUnspecified, + "email", + false, + ), + user.NewUserDeactivatedEvent( + context.Background(), + &user.NewAggregate("userID", "org1").Aggregate, + ), + ), + ), + args: args{ + ctx: context.Background(), + userID: "userID", + resourceOwner: "org1", + userErrorID: "TEST-1", + orgErrorID: "TEST-1-ORG", + }, + wantErr: zerrors.IsPreconditionFailed, + }, + { + name: "org not active", + eventstore: expectEventstore( + expectFilter( + user.NewHumanAddedEvent( + context.Background(), + &user.NewAggregate("userID", "org1").Aggregate, + "username", + "firstname", + "lastname", + "nickname", + "displayname", + language.English, + domain.GenderUnspecified, + "email", + false, + ), + ), + expectFilter( + org.NewOrgAddedEvent(context.Background(), + &org.NewAggregate("org1").Aggregate, + "org"), + org.NewOrgDeactivatedEvent(context.Background(), + &org.NewAggregate("org1").Aggregate), + ), + ), + args: args{ + ctx: context.Background(), + userID: "userID", + resourceOwner: "org1", + userErrorID: "TEST-2", + orgErrorID: "TEST-2-ORG", + }, + wantErr: zerrors.IsPreconditionFailed, + }, + { + name: "success", + eventstore: expectEventstore( + expectFilter( + user.NewHumanAddedEvent( + context.Background(), + &user.NewAggregate("userID", "org1").Aggregate, + "username", + "firstname", + "lastname", + "nickname", + "displayname", + language.English, + domain.GenderUnspecified, + "email", + false, + ), + ), + expectFilter( + org.NewOrgAddedEvent(context.Background(), + &org.NewAggregate("org1").Aggregate, + "org"), + ), + ), + args: args{ + ctx: context.Background(), + userID: "userID", + resourceOwner: "org1", + userErrorID: "TEST-3", + orgErrorID: "TEST-3-ORG", + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := &Commands{ + eventstore: tt.eventstore(t), + } + got, err := c.userStateForAuthentication(tt.args.ctx, tt.args.userID, tt.args.resourceOwner, tt.args.userErrorID, tt.args.orgErrorID) + if tt.wantErr != nil { + require.Error(t, err) + require.True(t, tt.wantErr(err)) + require.Nil(t, got) + return + } + require.NoError(t, err) + require.NotNil(t, got) + require.Equal(t, "userID", got.AggregateID) + }) + } +} diff --git a/internal/domain/org.go b/internal/domain/org.go index f9b1140d2..d1c09e8d3 100644 --- a/internal/domain/org.go +++ b/internal/domain/org.go @@ -47,3 +47,7 @@ func (s OrgState) Valid() bool { func (s OrgState) Exists() bool { return s != OrgStateRemoved && s != OrgStateUnspecified } + +func (s OrgState) IsActive() bool { + return s == OrgStateActive +} diff --git a/internal/query/access_token.go b/internal/query/access_token.go index 030ddda47..c1aa60794 100644 --- a/internal/query/access_token.go +++ b/internal/query/access_token.go @@ -11,6 +11,7 @@ import ( "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/eventstore" "github.com/zitadel/zitadel/internal/repository/oidcsession" + "github.com/zitadel/zitadel/internal/repository/org" "github.com/zitadel/zitadel/internal/repository/session" "github.com/zitadel/zitadel/internal/repository/user" "github.com/zitadel/zitadel/internal/telemetry/tracing" @@ -21,6 +22,7 @@ type OIDCSessionAccessTokenReadModel struct { eventstore.ReadModel UserID string + UserResourceOwner string SessionID string ClientID string Audience []string @@ -77,6 +79,7 @@ func (wm *OIDCSessionAccessTokenReadModel) Query() *eventstore.SearchQueryBuilde func (wm *OIDCSessionAccessTokenReadModel) reduceAdded(e *oidcsession.AddedEvent) { wm.UserID = e.UserID + wm.UserResourceOwner = e.UserResourceOwner wm.SessionID = e.SessionID wm.ClientID = e.ClientID wm.Audience = e.Audience @@ -119,7 +122,7 @@ func (q *Queries) ActiveAccessTokenByToken(ctx context.Context, token string) (m if !model.AccessTokenExpiration.After(time.Now()) { return nil, zerrors.ThrowUnauthenticated(nil, "QUERY-SAF3rf", "Errors.OIDCSession.Token.Expired") } - if err = q.checkSessionNotTerminatedAfter(ctx, model.SessionID, model.UserID, model.Position, model.UserAgent.GetFingerprintID()); err != nil { + if err = q.checkSessionNotTerminatedAfter(ctx, model.SessionID, model.UserID, model.UserResourceOwner, model.Position, model.UserAgent.GetFingerprintID()); err != nil { return nil, err } return model, nil @@ -139,19 +142,20 @@ func (q *Queries) accessTokenByOIDCSessionAndTokenID(ctx context.Context, oidcSe return model, nil } -// checkSessionNotTerminatedAfter checks if a [session.TerminateType] event (or user events leading to a session termination) +// checkSessionNotTerminatedAfter checks if a [session.TerminateType] event (or user / org events leading to a session termination) // occurred after a certain time and will return an error if so. -func (q *Queries) checkSessionNotTerminatedAfter(ctx context.Context, sessionID, userID string, position decimal.Decimal, fingerprintID string) (err error) { +func (q *Queries) checkSessionNotTerminatedAfter(ctx context.Context, sessionID, userID, resourceOwner string, position decimal.Decimal, fingerprintID string) (err error) { ctx, span := tracing.NewSpan(ctx) defer func() { span.EndWithError(err) }() - if sessionID == "" && userID == "" && fingerprintID == "" { + if sessionID == "" && userID == "" && fingerprintID == "" && resourceOwner == "" { return nil } model := &sessionTerminatedModel{ sessionID: sessionID, position: position, userID: userID, + resourceOwner: resourceOwner, fingerPrintID: fingerprintID, } err = q.eventstore.FilterToQueryReducer(ctx, model) @@ -169,6 +173,7 @@ type sessionTerminatedModel struct { position decimal.Decimal sessionID string userID string + resourceOwner string fingerPrintID string events int @@ -220,5 +225,15 @@ func (s *sessionTerminatedModel) Query() *eventstore.SearchQueryBuilder { Builder() } } + if s.resourceOwner != "" { + builder = builder.AddQuery(). + AggregateTypes(org.AggregateType). + AggregateIDs(s.resourceOwner). + EventTypes( + org.OrgDeactivatedEventType, + ). + PositionAfter(s.position). + Builder() + } return builder } diff --git a/internal/query/access_token_test.go b/internal/query/access_token_test.go new file mode 100644 index 000000000..cf223329a --- /dev/null +++ b/internal/query/access_token_test.go @@ -0,0 +1,120 @@ +package query + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golang.org/x/text/language" + + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/eventstore" + "github.com/zitadel/zitadel/internal/eventstore/repository" + "github.com/zitadel/zitadel/internal/repository/oidcsession" + "github.com/zitadel/zitadel/internal/repository/org" + "github.com/zitadel/zitadel/internal/zerrors" +) + +func eventFromEventPusherWithCreationDateNow(event eventstore.Command) *repository.Event { + e := eventFromEventPusher(event) + e.CreationDate = time.Now() + return e +} + +func TestQueries_ActiveAccessTokenByToken(t *testing.T) { + type fields struct { + eventstore func(*testing.T) *eventstore.Eventstore + } + type args struct { + token string + } + type res struct { + wantUserID string + err error + } + tests := []struct { + name string + fields fields + args args + res res + }{ + { + name: "invalid token format", + fields: fields{ + eventstore: expectEventstore(), + }, + args: args{token: "invalid"}, + res: res{ + err: zerrors.ThrowUnauthenticated(nil, "QUERY-LJK2W", "Errors.OIDCSession.Token.Invalid"), + }, + }, + { + name: "active token", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusherWithCreationDateNow( + oidcsession.NewAddedEvent(context.Background(), &oidcsession.NewAggregate("V2_oidcSessionID", "org1").Aggregate, + "userID", "org1", "sessionID", "clientID", []string{"audience"}, []string{"openid"}, + []domain.UserAuthMethodType{domain.UserAuthMethodTypePassword}, time.Now(), "nonce", &language.English, nil, + ), + ), + eventFromEventPusherWithCreationDateNow( + oidcsession.NewAccessTokenAddedEvent(context.Background(), &oidcsession.NewAggregate("V2_oidcSessionID", "org1").Aggregate, + "at_accessTokenID", []string{"openid"}, time.Hour, domain.TokenReasonAuthRequest, nil), + ), + ), + expectFilter(), // no session/user/org termination after token + ), + }, + args: args{token: "V2_oidcSessionID-at_accessTokenID"}, + res: res{ + wantUserID: "userID", + }, + }, + { + name: "org deactivated after token issuance", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusherWithCreationDateNow( + oidcsession.NewAddedEvent(context.Background(), &oidcsession.NewAggregate("V2_oidcSessionID", "org1").Aggregate, + "userID", "org1", "sessionID", "clientID", []string{"audience"}, []string{"openid"}, + []domain.UserAuthMethodType{domain.UserAuthMethodTypePassword}, time.Now(), "nonce", &language.English, nil, + ), + ), + eventFromEventPusherWithCreationDateNow( + oidcsession.NewAccessTokenAddedEvent(context.Background(), &oidcsession.NewAggregate("V2_oidcSessionID", "org1").Aggregate, + "at_accessTokenID", []string{"openid"}, time.Hour, domain.TokenReasonAuthRequest, nil), + ), + ), + expectFilter( + eventFromEventPusherWithCreationDateNow( + org.NewOrgDeactivatedEvent(context.Background(), &org.NewAggregate("org1").Aggregate), + ), + ), + ), + }, + args: args{token: "V2_oidcSessionID-at_accessTokenID"}, + res: res{ + err: zerrors.ThrowUnauthenticated(nil, "QUERY-IJL3H", "Errors.OIDCSession.Token.Invalid"), + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + q := &Queries{ + eventstore: tt.fields.eventstore(t), + } + got, err := q.ActiveAccessTokenByToken(context.Background(), tt.args.token) + require.ErrorIs(t, err, tt.res.err) + if tt.res.err == nil { + require.NotNil(t, got) + assert.Equal(t, tt.res.wantUserID, got.UserID) + assert.Equal(t, "org1", got.UserResourceOwner) + } + }) + } +} diff --git a/internal/query/org.go b/internal/query/org.go index a6f3b2a00..11ec9d716 100644 --- a/internal/query/org.go +++ b/internal/query/org.go @@ -264,6 +264,20 @@ func (q *Queries) ExistsOrg(ctx context.Context, id, domain string) (verifiedID return org.ID, nil } +func (q *Queries) CheckOrgActive(ctx context.Context, orgID string) (err error) { + ctx, span := tracing.NewSpan(ctx) + defer func() { span.EndWithError(err) }() + + org, err := q.OrgByID(ctx, orgID) + if err != nil { + return err + } + if !org.State.IsActive() { + return zerrors.ThrowPreconditionFailed(nil, "QUERY-oR9nA", "Errors.Org.NotActive") + } + return nil +} + func (q *Queries) SearchOrgs(ctx context.Context, queries *OrgSearchQueries, permissionCheck domain_pkg.PermissionCheck) (*Orgs, error) { permissionCheckV2 := PermissionV2(ctx, permissionCheck) orgs, err := q.searchOrgs(ctx, queries, permissionCheckV2) diff --git a/internal/query/projection/session.go b/internal/query/projection/session.go index bf6f2cbda..0f51fa8ad 100644 --- a/internal/query/projection/session.go +++ b/internal/query/projection/session.go @@ -8,6 +8,7 @@ import ( old_handler "github.com/zitadel/zitadel/internal/eventstore/handler" "github.com/zitadel/zitadel/internal/eventstore/handler/v2" "github.com/zitadel/zitadel/internal/repository/instance" + "github.com/zitadel/zitadel/internal/repository/org" "github.com/zitadel/zitadel/internal/repository/session" "github.com/zitadel/zitadel/internal/repository/user" "github.com/zitadel/zitadel/internal/zerrors" @@ -183,6 +184,19 @@ func (p *sessionProjection) Reducers() []handler.AggregateReducer { }, }, }, + { + Aggregate: org.AggregateType, + EventReducers: []handler.EventReducer{ + { + Event: org.OrgDeactivatedEventType, + Reduce: p.reduceOwnerRemoved, + }, + { + Event: org.OrgRemovedEventType, + Reduce: p.reduceOwnerRemoved, + }, + }, + }, } } @@ -494,3 +508,20 @@ func (p *sessionProjection) reduceUserStateNotActive(event eventstore.Event) (_ }, ), nil } + +func (p *sessionProjection) reduceOwnerRemoved(event eventstore.Event) (*handler.Statement, error) { + switch t := event.(type) { + case *org.OrgDeactivatedEvent, *org.OrgRemovedEvent: + // ok + default: + return nil, zerrors.ThrowInvalidArgumentf(nil, "HANDL-oR9nA", "reduce.wrong.event.type %v", t) + } + + return handler.NewDeleteStatement( + event, + []handler.Condition{ + handler.NewCond(SessionColumnUserResourceOwner, event.Aggregate().ID), + handler.NewCond(SessionColumnInstanceID, event.Aggregate().InstanceID), + }, + ), nil +} diff --git a/internal/query/projection/session_test.go b/internal/query/projection/session_test.go index 0575ea67b..3b123bb4c 100644 --- a/internal/query/projection/session_test.go +++ b/internal/query/projection/session_test.go @@ -10,6 +10,7 @@ import ( "github.com/zitadel/zitadel/internal/eventstore" "github.com/zitadel/zitadel/internal/eventstore/handler/v2" "github.com/zitadel/zitadel/internal/repository/instance" + "github.com/zitadel/zitadel/internal/repository/org" "github.com/zitadel/zitadel/internal/repository/session" "github.com/zitadel/zitadel/internal/repository/user" "github.com/zitadel/zitadel/internal/zerrors" @@ -492,6 +493,58 @@ func TestSessionProjection_reduces(t *testing.T) { }, }, }, + { + name: "reduce OrgDeactivated", + args: args{ + event: getEvent(testEvent( + org.OrgDeactivatedEventType, + org.AggregateType, + nil, + ), org.OrgDeactivatedEventMapper), + }, + reduce: (&sessionProjection{}).reduceOwnerRemoved, + want: wantReduce{ + aggregateType: eventstore.AggregateType("org"), + sequence: 15, + executer: &testExecuter{ + executions: []execution{ + { + expectedStmt: "DELETE FROM projections.sessions8 WHERE (user_resource_owner = $1) AND (instance_id = $2)", + expectedArgs: []any{ + "agg-id", + "instance-id", + }, + }, + }, + }, + }, + }, + { + name: "reduce OrgRemoved", + args: args{ + event: getEvent(testEvent( + org.OrgRemovedEventType, + org.AggregateType, + nil, + ), org.OrgRemovedEventMapper), + }, + reduce: (&sessionProjection{}).reduceOwnerRemoved, + want: wantReduce{ + aggregateType: eventstore.AggregateType("org"), + sequence: 15, + executer: &testExecuter{ + executions: []execution{ + { + expectedStmt: "DELETE FROM projections.sessions8 WHERE (user_resource_owner = $1) AND (instance_id = $2)", + expectedArgs: []any{ + "agg-id", + "instance-id", + }, + }, + }, + }, + }, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/internal/query/userinfo_by_id.sql b/internal/query/userinfo_by_id.sql index 99b8d1d16..ee1194744 100644 --- a/internal/query/userinfo_by_id.sql +++ b/internal/query/userinfo_by_id.sql @@ -2,7 +2,8 @@ with usr as ( select u.id, u.creation_date, u.change_date, u.sequence, u.state, u.resource_owner, u.username, n.login_name as preferred_login_name from projections.users14 u left join projections.login_names3 n on u.id = n.user_id and u.instance_id = n.instance_id - where u.id = $1 and u.state = 1 -- only allow active users + join projections.orgs1 o on o.id = u.resource_owner and o.instance_id = u.instance_id and o.org_state = 1 + where u.id = $1 and u.state = 1 -- only allow active users in active organizations and u.instance_id = $2 and n.is_primary = true ), diff --git a/internal/static/i18n/ar.yaml b/internal/static/i18n/ar.yaml index 3dacf77f4..42cf8c75b 100644 --- a/internal/static/i18n/ar.yaml +++ b/internal/static/i18n/ar.yaml @@ -205,6 +205,7 @@ Errors: AlreadyActive: "المنظمة نشطة بالفعل" Empty: "المنظمة فارغة" NotFound: "المنظمة غير موجودة" + NotActive: "المنظمة غير نشطة" NotChanged: "المنظمة لم تتغير" DefaultOrgNotDeletable: "لا يجب حذف المنظمة الافتراضية" ZitadelOrgNotDeletable: "المنظمة التي تحتوي على مشروع ZITADEL لا يجب حذفها" diff --git a/internal/static/i18n/bg.yaml b/internal/static/i18n/bg.yaml index c5927ec55..1dc561d47 100644 --- a/internal/static/i18n/bg.yaml +++ b/internal/static/i18n/bg.yaml @@ -204,6 +204,7 @@ Errors: AlreadyActive: "Организацията вече е активна" Empty: "Организацията е празна" NotFound: "Организацията не е намерена" + NotActive: "Организацията не е активна" NotChanged: "Организацията не е променена" DefaultOrgNotDeletable: "Организацията по подразбиране не трябва да се изтрива" ZitadelOrgNotDeletable: "Организация с проект Zitadel не трябва да се изтрива" diff --git a/internal/static/i18n/cs.yaml b/internal/static/i18n/cs.yaml index 0abd3951d..b1d23fae2 100644 --- a/internal/static/i18n/cs.yaml +++ b/internal/static/i18n/cs.yaml @@ -204,6 +204,7 @@ Errors: AlreadyActive: "Organizace je již aktivní" Empty: "Organizace je prázdná" NotFound: "Organizace nenalezena" + NotActive: "Organizace není aktivní" NotChanged: "Organizace nezměněna" DefaultOrgNotDeletable: "Výchozí organizace nesmí být smazána" ZitadelOrgNotDeletable: "Organizaci s projektem Zitadel nelze smazat" diff --git a/internal/static/i18n/de.yaml b/internal/static/i18n/de.yaml index be2c12885..ab01057cb 100644 --- a/internal/static/i18n/de.yaml +++ b/internal/static/i18n/de.yaml @@ -204,6 +204,7 @@ Errors: AlreadyActive: "Organisation ist bereits aktiv" Empty: "Organisation ist leer" NotFound: "Organisation konnte nicht gefunden werden" + NotActive: "Organisation ist nicht aktiv" NotChanged: "Organisation wurde nicht verändert" DefaultOrgNotDeletable: "Default Organisation kann nicht gelöscht werden" ZitadelOrgNotDeletable: "Organisation mit Zitadel Projekt kann nicht gelöscht werden" diff --git a/internal/static/i18n/en.yaml b/internal/static/i18n/en.yaml index bd0cb85c6..204f797d8 100644 --- a/internal/static/i18n/en.yaml +++ b/internal/static/i18n/en.yaml @@ -207,6 +207,7 @@ Errors: AlreadyActive: "Organisation is already active" Empty: "Organisation is empty" NotFound: "Organisation not found" + NotActive: "Organisation is not active" NotChanged: "Organisation not changed" DefaultOrgNotDeletable: "Default Organisation must not be deleted" ZitadelOrgNotDeletable: "Organisation with Zitadel project must not be deleted" diff --git a/internal/static/i18n/es.yaml b/internal/static/i18n/es.yaml index 8f5ee64e3..b531c9b73 100644 --- a/internal/static/i18n/es.yaml +++ b/internal/static/i18n/es.yaml @@ -204,6 +204,7 @@ Errors: AlreadyActive: "La organización ya está activada" Empty: "La organización está vacía" NotFound: "Organización no encontrada" + NotActive: "La organización no está activa" NotChanged: "La organización no ha cambiado" DefaultOrgNotDeletable: "La organización por defecto no debe borrarse" ZitadelOrgNotDeletable: "La organización que contiene el proyecto Zitadel no debe borrarse" diff --git a/internal/static/i18n/fr.yaml b/internal/static/i18n/fr.yaml index d24c8c959..d7c67bbda 100644 --- a/internal/static/i18n/fr.yaml +++ b/internal/static/i18n/fr.yaml @@ -204,6 +204,7 @@ Errors: AlreadyActive: "L'organisation est déjà active" Empty: "L'organisation est vide" NotFound: "Organisation non trouvée" + NotActive: "L'organisation n'est pas active" NotChanged: "L'organisation n'a pas changé" DefaultOrgNotDeletable: "L'organisation par défault ne doit pas être supprimée" ZitadelOrgNotDeletable: "L'organisation avec Zitadel project ne doit pas être supprimée" diff --git a/internal/static/i18n/hu.yaml b/internal/static/i18n/hu.yaml index 4059ebec8..cfa10a862 100644 --- a/internal/static/i18n/hu.yaml +++ b/internal/static/i18n/hu.yaml @@ -204,6 +204,7 @@ Errors: AlreadyActive: "A szervezet már aktív" Empty: "A szervezet üres" NotFound: "Szervezet nem található" + NotActive: "A szervezet nem aktív" NotChanged: "A szervezet nem változott" DefaultOrgNotDeletable: "Az alapértelmezett szervezetet nem szabad törölni" ZitadelOrgNotDeletable: "A Zitadel projekttel rendelkező szervezetet nem szabad törölni" diff --git a/internal/static/i18n/id.yaml b/internal/static/i18n/id.yaml index a67bd6d18..a9d21bb1a 100644 --- a/internal/static/i18n/id.yaml +++ b/internal/static/i18n/id.yaml @@ -204,6 +204,7 @@ Errors: AlreadyActive: "Organisasi sudah aktif" Empty: "Organisasi kosong" NotFound: "Organisasi tidak ditemukan" + NotActive: "Organisasi tidak aktif" NotChanged: "Organisasi tidak berubah" DefaultOrgNotDeletable: "Organisasi Default tidak boleh dihapus" ZitadelOrgNotDeletable: "Organisasi dengan proyek Zitadel tidak boleh dihapus" diff --git a/internal/static/i18n/it.yaml b/internal/static/i18n/it.yaml index f0ca69a96..5b0a0ec70 100644 --- a/internal/static/i18n/it.yaml +++ b/internal/static/i18n/it.yaml @@ -204,6 +204,7 @@ Errors: AlreadyActive: "L'organizzazione è già attiva" Empty: "L'organizzazione è vuota" NotFound: "Organizzazione non trovata" + NotActive: "L'organizzazione non è attiva" NotChanged: "Organizzazione non cambiata" DefaultOrgNotDeletable: "L'organizzazione predefinita non deve essere cancellata" ZitadelOrgNotDeletable: "L'organizzazione con il progetto Zitadel non deve essere cancellata" diff --git a/internal/static/i18n/ja.yaml b/internal/static/i18n/ja.yaml index fdf1d0020..6444671fd 100644 --- a/internal/static/i18n/ja.yaml +++ b/internal/static/i18n/ja.yaml @@ -205,6 +205,7 @@ Errors: AlreadyActive: "組織はすでにアクティブです" Empty: "組織は空です" NotFound: "組織が見つかりません" + NotActive: "組織はアクティブではありません" NotChanged: "組織は変更されていません" DefaultOrgNotDeletable: "デフォルトの組織は削除できません" ZitadelOrgNotDeletable: "Zitadelプロジェクトの組織は削除できません" diff --git a/internal/static/i18n/ko.yaml b/internal/static/i18n/ko.yaml index 9306d416d..1e97edc7a 100644 --- a/internal/static/i18n/ko.yaml +++ b/internal/static/i18n/ko.yaml @@ -205,6 +205,7 @@ Errors: AlreadyActive: "조직이 이미 활성화되었습니다" Empty: "조직이 비어 있습니다" NotFound: "조직을 찾을 수 없습니다" + NotActive: "조직이 활성 상태가 아닙니다" NotChanged: "조직이 변경되지 않았습니다" DefaultOrgNotDeletable: "기본 조직은 삭제할 수 없습니다" ZitadelOrgNotDeletable: "Zitadel 프로젝트가 포함된 조직은 삭제할 수 없습니다" diff --git a/internal/static/i18n/mk.yaml b/internal/static/i18n/mk.yaml index bd5ba9ef7..103445160 100644 --- a/internal/static/i18n/mk.yaml +++ b/internal/static/i18n/mk.yaml @@ -203,6 +203,7 @@ Errors: AlreadyActive: "Организацијата е веќе активна" Empty: "Организацијата е празна" NotFound: "Организацијата не е пронајдена" + NotActive: "Организацијата не е активна" NotChanged: "Организацијата не е променета" DefaultOrgNotDeletable: "Стандардната организација не смее да биде избришана" ZitadelOrgNotDeletable: "Организацијата со Zitadel проект не смее да биде избришана" diff --git a/internal/static/i18n/nl.yaml b/internal/static/i18n/nl.yaml index 7ffc5bc1b..fcd953929 100644 --- a/internal/static/i18n/nl.yaml +++ b/internal/static/i18n/nl.yaml @@ -204,6 +204,7 @@ Errors: AlreadyActive: "Organisatie is al actief" Empty: "Organisatie is leeg" NotFound: "Organisatie niet gevonden" + NotActive: "Organisatie is niet actief" NotChanged: "Organisatie is niet veranderd" DefaultOrgNotDeletable: "Standaard organisatie kan niet worden verwijderd" ZitadelOrgNotDeletable: "Organisatie met Zitadel-project kan niet worden verwijderd" diff --git a/internal/static/i18n/pl.yaml b/internal/static/i18n/pl.yaml index afcefb7fc..7d8b6dba2 100644 --- a/internal/static/i18n/pl.yaml +++ b/internal/static/i18n/pl.yaml @@ -204,6 +204,7 @@ Errors: AlreadyActive: "Organizacja jest już aktywna" Empty: "Organizacja jest pusta" NotFound: "Organizacja nie znaleziona" + NotActive: "Organizacja nie jest aktywna" NotChanged: "Organizacja nie zmieniona" DefaultOrgNotDeletable: "Domyślna organizacja nie może być usunięta" ZitadelOrgNotDeletable: "Organizacja z projektem Zitadel nie może być usunięta" diff --git a/internal/static/i18n/pt.yaml b/internal/static/i18n/pt.yaml index 11dec9cdb..dbeb857c4 100644 --- a/internal/static/i18n/pt.yaml +++ b/internal/static/i18n/pt.yaml @@ -203,6 +203,7 @@ Errors: AlreadyActive: "Organização já está ativa" Empty: "Organização está vazia" NotFound: "Organização não encontrada" + NotActive: "A organização não está ativa" NotChanged: "Organização não alterada" DefaultOrgNotDeletable: "A organização padrão não pode ser excluída" ZitadelOrgNotDeletable: "A organização com o projeto Zitadel não pode ser excluída" diff --git a/internal/static/i18n/ro.yaml b/internal/static/i18n/ro.yaml index 71e0509fe..79f4d6fa6 100644 --- a/internal/static/i18n/ro.yaml +++ b/internal/static/i18n/ro.yaml @@ -205,6 +205,7 @@ Errors: AlreadyActive: "Organizația este deja activă" Empty: "Organizația este goală" NotFound: "Organizația nu a fost găsită" + NotActive: "Organizația nu este activă" NotChanged: "Organizația nu a fost schimbată" DefaultOrgNotDeletable: "Organizația implicită nu trebuie să fie ștearsă" ZitadelOrgNotDeletable: "Organizația cu proiectul Zitadel nu trebuie să fie ștearsă" diff --git a/internal/static/i18n/ru.yaml b/internal/static/i18n/ru.yaml index 2acf7f87b..36c3ad8d7 100644 --- a/internal/static/i18n/ru.yaml +++ b/internal/static/i18n/ru.yaml @@ -204,6 +204,7 @@ Errors: AlreadyActive: "Организация уже активна" Empty: "Организация не заполнена" NotFound: "Организация не найдена" + NotActive: "Организация неактивна" NotChanged: "Организация не изменена" DefaultOrgNotDeletable: "Организация по умолчанию не может быть удалена" ZitadelOrgNotDeletable: "Невозможно удалить организацию с проектом Zitadel" diff --git a/internal/static/i18n/sv.yaml b/internal/static/i18n/sv.yaml index f57683050..c61b03fba 100644 --- a/internal/static/i18n/sv.yaml +++ b/internal/static/i18n/sv.yaml @@ -204,6 +204,7 @@ Errors: AlreadyActive: "Organisationen är redan aktiv" Empty: "Organisationen är tom" NotFound: "Organisationen hittades inte" + NotActive: "Organisationen är inte aktiv" NotChanged: "Organisationen ändrades inte" DefaultOrgNotDeletable: "Standardorganisationen får inte raderas" ZitadelOrgNotDeletable: "Organisationen med Zitadel-projekt får inte raderas" diff --git a/internal/static/i18n/tr.yaml b/internal/static/i18n/tr.yaml index b6964e8a8..0653bca04 100644 --- a/internal/static/i18n/tr.yaml +++ b/internal/static/i18n/tr.yaml @@ -205,6 +205,7 @@ Errors: AlreadyActive: "Organizasyon zaten aktif" Empty: "Organizasyon boş" NotFound: "Organizasyon bulunamadı" + NotActive: "Organizasyon aktif değil" NotChanged: "Organizasyon değişmedi" DefaultOrgNotDeletable: "Varsayılan Organizasyon silinmemeli" ZitadelOrgNotDeletable: "Zitadel projesi olan organizasyon silinmemeli" diff --git a/internal/static/i18n/uk.yaml b/internal/static/i18n/uk.yaml index cd510e174..07302bc0f 100644 --- a/internal/static/i18n/uk.yaml +++ b/internal/static/i18n/uk.yaml @@ -199,6 +199,7 @@ Errors: AlreadyActive: "Організація вже активна" Empty: "Організація порожня" NotFound: "Організація не знайдена" + NotActive: "Організація неактивна" NotChanged: "Організація не змінена" DefaultOrgNotDeletable: "Організація за замовчуванням не може бути видалена" ZitadelOrgNotDeletable: "Організація з проектом Zitadel не може бути видалена" diff --git a/internal/static/i18n/zh.yaml b/internal/static/i18n/zh.yaml index 37b54b5e0..e539456c9 100644 --- a/internal/static/i18n/zh.yaml +++ b/internal/static/i18n/zh.yaml @@ -205,6 +205,7 @@ Errors: AlreadyActive: "组织已处于启用状态" Empty: "组织为空" NotFound: "未找到组织" + NotActive: "组织未激活" NotChanged: "组织信息未改变" DefaultOrgNotDeletable: "默认组织不应删除" ZitadelOrgNotDeletable: "不得删除与Zitadel项目有关的组织"