diff --git a/backend/v3/api/database.go b/backend/v3/api/database.go new file mode 100644 index 0000000000..b43984980d --- /dev/null +++ b/backend/v3/api/database.go @@ -0,0 +1,23 @@ +package api + +import ( + "github.com/zitadel/zitadel/backend/v3/storage/database" + v2beta "github.com/zitadel/zitadel/pkg/grpc/object/v2beta" +) + +func V2BetaTextFilterToDatabase(filter v2beta.TextQueryMethod) database.TextOperation { + switch filter { + case v2beta.TextQueryMethod_TEXT_QUERY_METHOD_EQUALS: + return database.TextOperationEqual + case v2beta.TextQueryMethod_TEXT_QUERY_METHOD_EQUALS_IGNORE_CASE: + return database.TextOperationEqualIgnoreCase + case v2beta.TextQueryMethod_TEXT_QUERY_METHOD_STARTS_WITH: + return database.TextOperationStartsWith + case v2beta.TextQueryMethod_TEXT_QUERY_METHOD_STARTS_WITH_IGNORE_CASE: + return database.TextOperationStartsWithIgnoreCase + case v2beta.TextQueryMethod_TEXT_QUERY_METHOD_CONTAINS, v2beta.TextQueryMethod_TEXT_QUERY_METHOD_CONTAINS_IGNORE_CASE, v2beta.TextQueryMethod_TEXT_QUERY_METHOD_ENDS_WITH, v2beta.TextQueryMethod_TEXT_QUERY_METHOD_ENDS_WITH_IGNORE_CASE: + panic("unimplemented text query method: " + filter.String()) + default: + panic("unknown text query method: " + filter.String()) + } +} diff --git a/backend/v3/api/domain.go b/backend/v3/api/domain.go new file mode 100644 index 0000000000..445be35e9c --- /dev/null +++ b/backend/v3/api/domain.go @@ -0,0 +1,27 @@ +package api + +import ( + "github.com/zitadel/zitadel/backend/v3/domain" + filter "github.com/zitadel/zitadel/pkg/grpc/filter/v2beta" + org "github.com/zitadel/zitadel/pkg/grpc/org/v2beta" +) + +func V2BetaOrgStateToDomain(state org.OrgState) domain.OrgState { + switch state { + case org.OrgState_ORG_STATE_ACTIVE: + return domain.OrgStateActive + case org.OrgState_ORG_STATE_INACTIVE: + return domain.OrgStateInactive + default: + // TODO: removed is not supported in the domain + panic("unknown org state: " + state.String()) + } +} + +func V2BetaPaginationToDomain(pagination *filter.PaginationRequest) domain.Pagination { + return domain.Pagination{ + Limit: pagination.Limit, + Offset: uint32(pagination.Offset), + Ascending: pagination.Asc, + } +} diff --git a/backend/v3/api/org/v2/org.go b/backend/v3/api/org/v2/org.go index f450692151..d06e1b6a0a 100644 --- a/backend/v3/api/org/v2/org.go +++ b/backend/v3/api/org/v2/org.go @@ -2,70 +2,197 @@ package org import ( "context" + "slices" "connectrpc.com/connect" + "google.golang.org/protobuf/types/known/timestamppb" + "github.com/zitadel/zitadel/backend/v3/api" + "github.com/zitadel/zitadel/backend/v3/domain" + "github.com/zitadel/zitadel/backend/v3/storage/database" + "github.com/zitadel/zitadel/internal/api/authz" + filter "github.com/zitadel/zitadel/pkg/grpc/filter/v2beta" org "github.com/zitadel/zitadel/pkg/grpc/org/v2beta" ) -// import ( -// "context" - -// "github.com/zitadel/zitadel/backend/v3/domain" -// "github.com/zitadel/zitadel/pkg/grpc/org/v2" -// ) - -// func CreateOrg(ctx context.Context, req *org.AddOrganizationRequest) (resp *org.AddOrganizationResponse, err error) { -// cmd := domain.NewAddOrgCommand( -// req.GetName(), -// addOrgAdminToCommand(req.GetAdmins()...)..., -// ) -// err = domain.Invoke(ctx, cmd) -// if err != nil { -// return nil, err -// } -// return &org.AddOrganizationResponse{ -// OrganizationId: cmd.ID, -// }, nil -// } - -// func addOrgAdminToCommand(admins ...*org.AddOrganizationRequest_Admin) []*domain.AddMemberCommand { -// cmds := make([]*domain.AddMemberCommand, len(admins)) -// for i, admin := range admins { -// cmds[i] = &domain.AddMemberCommand{ -// UserID: admin.GetUserId(), -// Roles: admin.GetRoles(), -// } -// } -// return cmds -// } - // ActivateOrganization implements [orgconnect.OrganizationServiceHandler]. func (s *Server) ActivateOrganization(ctx context.Context, req *connect.Request[org.ActivateOrganizationRequest]) (*connect.Response[org.ActivateOrganizationResponse], error) { - return s.UnimplementedOrganizationServiceHandler.ActivateOrganization(ctx, req) + err := domain.Invoke(ctx, domain.NewActivateOrganizationCommand( + authz.GetInstance(ctx).InstanceID(), + req.Msg.GetId(), + )) + if err != nil { + return nil, err + } + // DISCUSS(adlerhurst): does returning the ChangeDate bring any value? + return connect.NewResponse(&org.ActivateOrganizationResponse{ + ChangeDate: timestamppb.Now(), + }), nil } // CreateOrganization implements [orgconnect.OrganizationServiceHandler]. func (s *Server) CreateOrganization(ctx context.Context, req *connect.Request[org.CreateOrganizationRequest]) (*connect.Response[org.CreateOrganizationResponse], error) { - return s.UnimplementedOrganizationServiceHandler.CreateOrganization(ctx, req) + // TODO: Implement admins + opts := make([]domain.CreateOrganizationCommandOpts, 0, 1+len(req.Msg.Admins)) + if req.Msg.Id != nil { + opts = append(opts, domain.WithOrganizationID(req.Msg.GetId())) + } + cmd := domain.NewCreateOrganizationCommand( + authz.GetInstance(ctx).InstanceID(), + req.Msg.GetName(), + opts..., + ) + err := domain.Invoke(ctx, cmd) + if err != nil { + return nil, err + } + return connect.NewResponse(&org.CreateOrganizationResponse{ + Id: cmd.ID, + CreationDate: timestamppb.New(cmd.CreatedAt), + }), nil } // DeactivateOrganization implements [orgconnect.OrganizationServiceHandler]. func (s *Server) DeactivateOrganization(ctx context.Context, req *connect.Request[org.DeactivateOrganizationRequest]) (*connect.Response[org.DeactivateOrganizationResponse], error) { - return s.UnimplementedOrganizationServiceHandler.DeactivateOrganization(ctx, req) + err := domain.Invoke(ctx, domain.NewDeactivateOrganizationCommand( + authz.GetInstance(ctx).InstanceID(), + req.Msg.GetId(), + )) + if err != nil { + return nil, err + } + // DISCUSS(adlerhurst): does returning the ChangeDate bring any value? + return connect.NewResponse(&org.DeactivateOrganizationResponse{ + ChangeDate: timestamppb.Now(), + }), nil } // DeleteOrganization implements [orgconnect.OrganizationServiceHandler]. func (s *Server) DeleteOrganization(ctx context.Context, req *connect.Request[org.DeleteOrganizationRequest]) (*connect.Response[org.DeleteOrganizationResponse], error) { - return s.UnimplementedOrganizationServiceHandler.DeleteOrganization(ctx, req) + err := domain.Invoke(ctx, domain.NewDeleteOrganizationCommand( + authz.GetInstance(ctx).InstanceID(), + req.Msg.GetId(), + )) + if err != nil { + return nil, err + } + // DISCUSS(adlerhurst): does returning the DeletionDate bring any value? + return connect.NewResponse(&org.DeleteOrganizationResponse{ + DeletionDate: timestamppb.Now(), + }), nil } // ListOrganizations implements [orgconnect.OrganizationServiceHandler]. func (s *Server) ListOrganizations(ctx context.Context, req *connect.Request[org.ListOrganizationsRequest]) (*connect.Response[org.ListOrganizationsResponse], error) { - return s.UnimplementedOrganizationServiceHandler.ListOrganizations(ctx, req) + opts := orgFiltersToDomain(req.Msg.GetFilter()) + opts = slices.Grow(opts, 2) + opts = append(opts, api.V2BetaPaginationToDomain(req.Msg.GetPagination())) + if req.Msg.SortingColumn != org.OrgFieldName_ORG_FIELD_NAME_UNSPECIFIED { + opts = append(opts, domain.WithOrgQuerySortingColumn(orgFieldNameToDatabase(req.Msg.SortingColumn))) + } + + query := domain.NewOrgsQuery( + authz.GetInstance(ctx).InstanceID(), + opts..., + ) + err := domain.Invoke(ctx, query) + if err != nil { + return nil, err + } + return connect.NewResponse(&org.ListOrganizationsResponse{ + Organizations: orgsFromDomain(query.Result), + Pagination: &filter.PaginationResponse{ + AppliedLimit: uint64(req.Msg.Pagination.Limit), + // TotalResult: TODO(adlerhurst): needs implementation in lower layers + }, + }), nil } // UpdateOrganization implements [orgconnect.OrganizationServiceHandler]. func (s *Server) UpdateOrganization(ctx context.Context, req *connect.Request[org.UpdateOrganizationRequest]) (*connect.Response[org.UpdateOrganizationResponse], error) { - return s.UnimplementedOrganizationServiceHandler.UpdateOrganization(ctx, req) + opts := make([]domain.UpdateOrganizationCommandOpts, 0, 1) + if req.Msg.Name != "" { + opts = append(opts, domain.WithOrganizationName(req.Msg.GetName())) + } + + err := domain.Invoke(ctx, domain.NewUpdateOrganizationCommand( + authz.GetInstance(ctx).InstanceID(), + req.Msg.GetId(), + opts..., + )) + if err != nil { + return nil, err + } + return connect.NewResponse(&org.UpdateOrganizationResponse{ + ChangeDate: timestamppb.Now(), + }), nil +} + +func orgFiltersToDomain(filters []*org.OrganizationSearchFilter) []domain.OrgsQueryOpts { + opts := make([]domain.OrgsQueryOpts, len(filters)) + for i, filter := range filters { + opts[i] = orgFilterToDomain(filter) + } + return opts +} + +func orgFilterToDomain(filter *org.OrganizationSearchFilter) domain.OrgsQueryOpts { + switch f := filter.Filter.(type) { + case *org.OrganizationSearchFilter_NameFilter: + return domain.WithOrgByNameQuery(api.V2BetaTextFilterToDatabase(f.NameFilter.Method), f.NameFilter.Name) + case *org.OrganizationSearchFilter_DomainFilter: + return domain.WithOrgByDomainQuery(api.V2BetaTextFilterToDatabase(f.DomainFilter.Method), f.DomainFilter.Domain) + case *org.OrganizationSearchFilter_IdFilter: + return domain.WithOrgByIDQuery(f.IdFilter.Id) + case *org.OrganizationSearchFilter_StateFilter: + return domain.WithOrgByStateQuery(api.V2BetaOrgStateToDomain(f.StateFilter.State)) + default: + panic("unknown organization search filter: " + filter.String()) + } +} + +func orgsFromDomain(orgs []*domain.Organization) []*org.Organization { + result := make([]*org.Organization, len(orgs)) + for i, o := range orgs { + result[i] = &org.Organization{ + Id: o.ID, + Name: o.Name, + State: orgStateFromDomain(o.State), + CreationDate: timestamppb.New(o.CreatedAt), + ChangedDate: timestamppb.New(o.UpdatedAt), + PrimaryDomain: orgPrimaryDomainFromDomain(o.Domains), + } + } + return result +} + +func orgStateFromDomain(state domain.OrgState) org.OrgState { + switch state { + case domain.OrgStateActive: + return org.OrgState_ORG_STATE_ACTIVE + case domain.OrgStateInactive: + return org.OrgState_ORG_STATE_INACTIVE + default: + return org.OrgState_ORG_STATE_UNSPECIFIED + } +} + +func orgPrimaryDomainFromDomain(domains []*domain.OrganizationDomain) string { + for _, d := range domains { + if d.IsPrimary { + return d.Domain + } + } + return "" +} + +func orgFieldNameToDatabase(fieldName org.OrgFieldName) func(query *domain.OrgsQuery) database.Column { + switch fieldName { + case org.OrgFieldName_ORG_FIELD_NAME_NAME: + return domain.OrderOrgsByName + case org.OrgFieldName_ORG_FIELD_NAME_CREATION_DATE: + return domain.OrderOrgsByCreationDate + default: + return nil + } } diff --git a/backend/v3/domain/organization.go b/backend/v3/domain/organization.go index 44179027b9..f1c1e3e019 100644 --- a/backend/v3/domain/organization.go +++ b/backend/v3/domain/organization.go @@ -25,81 +25,275 @@ type Organization struct { Domains []*OrganizationDomain `json:"domains,omitempty" db:"-"` } -// OrgIdentifierCondition is used to help specify a single Organization, -// it will either be used as the organization ID or organization name, -// as organizations can be identified either using (instanceID + ID) OR (instanceID + name) -type OrgIdentifierCondition interface { - database.Condition -} +var _ Commander = (*CreateOrganizationCommand)(nil) -// organizationColumns define all the columns of the instance table. -type organizationColumns interface { - // IDColumn returns the column for the id field. - // `qualified` indicates if the column should be qualified with the table name. - IDColumn(qualified bool) database.Column - // NameColumn returns the column for the name field. - // `qualified` indicates if the column should be qualified with the table name. - NameColumn(qualified bool) database.Column - // InstanceIDColumn returns the column for the default org id field - // `qualified` indicates if the column should be qualified with the table name. - InstanceIDColumn(qualified bool) database.Column - // StateColumn returns the column for the name field. - // `qualified` indicates if the column should be qualified with the table name. - StateColumn(qualified bool) database.Column - // CreatedAtColumn returns the column for the created at field. - // `qualified` indicates if the column should be qualified with the table name. - CreatedAtColumn(qualified bool) database.Column - // UpdatedAtColumn returns the column for the updated at field. - // `qualified` indicates if the column should be qualified with the table name. - UpdatedAtColumn(qualified bool) database.Column -} - -// organizationConditions define all the conditions for the instance table. -type organizationConditions interface { - // IDCondition returns an equal filter on the id field. - IDCondition(instanceID string) OrgIdentifierCondition - // NameCondition returns a filter on the name field. - NameCondition(name string) OrgIdentifierCondition - // InstanceIDCondition returns a filter on the instance id field. - InstanceIDCondition(instanceID string) database.Condition - // StateCondition returns a filter on the name field. - StateCondition(state OrgState) database.Condition -} - -// organizationChanges define all the changes for the instance table. -type organizationChanges interface { - // SetName sets the name column. - SetName(name string) database.Change - // SetState sets the name column. - SetState(state OrgState) database.Change -} - -// OrganizationRepository is the interface for the instance repository. -type OrganizationRepository interface { - organizationColumns - organizationConditions - organizationChanges - - Get(ctx context.Context, opts ...database.QueryOption) (*Organization, error) - List(ctx context.Context, opts ...database.QueryOption) ([]*Organization, error) - - Create(ctx context.Context, instance *Organization) error - Update(ctx context.Context, id OrgIdentifierCondition, instance_id string, changes ...database.Change) (int64, error) - Delete(ctx context.Context, id OrgIdentifierCondition, instance_id string) (int64, error) - - // Domains returns the domain sub repository for the organization. - // If shouldLoad is true, the domains will be loaded from the database and written to the [Organization].Domains field. - // If shouldLoad is set to true once, the Domains field will be set even if shouldLoad is false in the future. - Domains(shouldLoad bool) OrganizationDomainRepository -} - -type CreateOrganization struct { +type CreateOrganizationCommand struct { + InstanceID string `json:"instanceId"` + // ID is optional, if not set a new ID will be generated. + // It can be set using the [WithOrganizationID] option in [NewCreateOrganizationCommand]. + ID string `json:"id,omitempty"` Name string `json:"name"` + + // CreatedAt MUST NOT be set by the caller. + CreatedAt time.Time `json:"createdAt,omitzero"` + + // Admins represent the commands to create the administrators. + // The Commanders MUST either be [AddOrgMemberCommand] or [CreateOrgMemberCommand]. + Admins []Commander `json:"admins,omitempty"` } -// MemberRepository is a sub repository of the org repository and maybe the instance repository. -type MemberRepository interface { - AddMember(ctx context.Context, orgID, userID string, roles []string) error - SetMemberRoles(ctx context.Context, orgID, userID string, roles []string) error - RemoveMember(ctx context.Context, orgID, userID string) error +type CreateOrganizationCommandOpts interface { + applyOnCreateOrganizationCommand(cmd *CreateOrganizationCommand) +} + +func NewCreateOrganizationCommand(instanceID, name string, opts ...CreateOrganizationCommandOpts) *CreateOrganizationCommand { + cmd := &CreateOrganizationCommand{ + InstanceID: instanceID, + Name: name, + } + for _, opt := range opts { + opt.applyOnCreateOrganizationCommand(cmd) + } + return cmd +} + +// Execute implements [Commander]. +// +// DISCUSS(adlerhurst): As we need to do validation to make sure a command contains all the data required +// we can consider the following options: +// 1. Validate the command before executing it, which is what we do here. +// 2. Create an invoker which checks if the struct has a `Validate() error` method and call it in the chain of invokers. +// While the the first one is more straightforward it bloats the execute method with validation logic. +// The second one would allow us to keep the execute method clean, but could be more error prone if the method gets missed during implementation. +func (cmd *CreateOrganizationCommand) Execute(ctx context.Context, opts *CommandOpts) (err error) { + if cmd.ID == "" { + cmd.ID, err = generateID() + if err != nil { + return err + } + } + close, err := opts.EnsureTx(ctx) + if err != nil { + return err + } + defer func() { err = close(ctx, err) }() + + err = orgRepo(opts.DB).Create(ctx, &Organization{ + ID: cmd.ID, + Name: cmd.Name, + InstanceID: cmd.InstanceID, + State: OrgStateActive, + }) + if err != nil { + return err + } + + for _, admin := range cmd.Admins { + if err = opts.Invoke(ctx, admin); err != nil { + return err + } + } + + return nil +} + +// String implements [Commander]. +func (CreateOrganizationCommand) String() string { + return "CreateOrganizationCommand" +} + +var ( + _ Commander = (*ActivateOrganizationCommand)(nil) +) + +type ActivateOrganizationCommand struct { + InstanceID string `json:"instanceId"` + OrgID string `json:"orgId"` + + // UpdatedAt MUST NOT be set by the caller. + UpdatedAt time.Time `json:"updatedAt,omitzero"` +} + +func NewActivateOrganizationCommand(instanceID, orgID string) *ActivateOrganizationCommand { + return &ActivateOrganizationCommand{ + InstanceID: instanceID, + OrgID: orgID, + } +} + +// Execute implements [Commander]. +func (cmd *ActivateOrganizationCommand) Execute(ctx context.Context, opts *CommandOpts) (err error) { + repo := orgRepo(opts.DB) + _, err = repo.Update(ctx, + repo.IDCondition(cmd.OrgID), + cmd.InstanceID, + repo.SetState(OrgStateActive), + ) + return err +} + +// String implements [Commander]. +func (ActivateOrganizationCommand) String() string { + return "ActivateOrganizationCommand" +} + +var ( + _ Commander = (*DeactivateOrganizationCommand)(nil) +) + +type DeactivateOrganizationCommand struct { + InstanceID string `json:"instanceId"` + OrgID string `json:"orgId"` + + // UpdatedAt MUST NOT be set by the caller. + UpdatedAt time.Time `json:"updatedAt,omitzero"` +} + +func NewDeactivateOrganizationCommand(instanceID, orgID string) *DeactivateOrganizationCommand { + return &DeactivateOrganizationCommand{ + InstanceID: instanceID, + OrgID: orgID, + } +} + +// Execute implements [Commander]. +func (cmd *DeactivateOrganizationCommand) Execute(ctx context.Context, opts *CommandOpts) (err error) { + repo := orgRepo(opts.DB) + _, err = repo.Update(ctx, + repo.IDCondition(cmd.OrgID), + cmd.InstanceID, + repo.SetState(OrgStateInactive), + ) + return err +} + +// String implements [Commander]. +func (DeactivateOrganizationCommand) String() string { + return "DeactivateOrganizationCommand" +} + +var ( + _ Commander = (*DeleteOrganizationCommand)(nil) +) + +type DeleteOrganizationCommand struct { + InstanceID string `json:"instanceId"` + OrgID string `json:"orgId"` +} + +func NewDeleteOrganizationCommand(instanceID, orgID string) *DeleteOrganizationCommand { + return &DeleteOrganizationCommand{ + InstanceID: instanceID, + OrgID: orgID, + } +} + +// Execute implements [Commander]. +func (cmd *DeleteOrganizationCommand) Execute(ctx context.Context, opts *CommandOpts) (err error) { + repo := orgRepo(opts.DB) + _, err = repo.Delete(ctx, + repo.IDCondition(cmd.OrgID), + cmd.InstanceID, + ) + return err +} + +// String implements [Commander]. +func (DeleteOrganizationCommand) String() string { + return "DeleteOrganizationCommand" +} + +var _ Commander = (*UpdateOrganizationCommand)(nil) + +type UpdateOrganizationCommand struct { + InstanceID string `json:"instanceId"` + OrgID string `json:"orgId"` + + repo OrganizationRepository + changes database.Changes + opts []UpdateOrganizationCommandOpts +} + +func NewUpdateOrganizationCommand(instanceID, orgID string, opts ...UpdateOrganizationCommandOpts) *UpdateOrganizationCommand { + return &UpdateOrganizationCommand{ + InstanceID: instanceID, + OrgID: orgID, + opts: opts, + } +} + +type UpdateOrganizationCommandOpts interface { + applyOnUpdateOrganizationCommand(cmd *UpdateOrganizationCommand) +} + +// Execute implements [Commander]. +func (cmd *UpdateOrganizationCommand) Execute(ctx context.Context, opts *CommandOpts) (err error) { + cmd.repo = orgRepo(opts.DB) + for _, opt := range cmd.opts { + opt.applyOnUpdateOrganizationCommand(cmd) + } + + if len(cmd.changes) == 0 { + return nil // No update needed if no changes are provided. + } + + _, err = cmd.repo.Update(ctx, + cmd.repo.IDCondition(cmd.OrgID), + cmd.InstanceID, + cmd.changes, + ) + return err +} + +// String implements [Commander]. +func (UpdateOrganizationCommand) String() string { + return "UpdateOrganizationCommand" +} + +type OrgsQueryOpts interface { + applyOnOrgsQuery(query *OrgsQuery) +} + +var _ Commander = (*OrgsQuery)(nil) + +type OrgsQuery struct { + InstanceID string + + opts []OrgsQueryOpts + repo OrganizationRepository + domainRepo OrganizationDomainRepository + conditions []database.Condition + pagination Pagination + + Result []*Organization +} + +func NewOrgsQuery(instanceID string, opts ...OrgsQueryOpts) *OrgsQuery { + return &OrgsQuery{ + InstanceID: instanceID, + opts: opts, + } +} + +// Execute implements [Commander]. +func (q *OrgsQuery) Execute(ctx context.Context, opts *CommandOpts) (err error) { + q.repo = orgRepo(opts.DB) + q.domainRepo = q.repo.Domains(true) + q.conditions = append(q.conditions, q.repo.InstanceIDCondition(q.InstanceID)) + for _, opt := range q.opts { + opt.applyOnOrgsQuery(q) + } + + q.Result, err = q.repo.List(ctx, + database.WithCondition(database.And(q.conditions...)), + database.WithLimit(q.pagination.Limit), + database.WithOffset(q.pagination.Offset), + database.WithOrderBy(!q.pagination.Ascending, q.pagination.OrderColumns...), + ) + return err +} + +// String implements [Commander]. +func (OrgsQuery) String() string { + return "OrgsQuery" } diff --git a/backend/v3/domain/organization_domain.go b/backend/v3/domain/organization_domain.go index 09147f408c..cc6c583dcb 100644 --- a/backend/v3/domain/organization_domain.go +++ b/backend/v3/domain/organization_domain.go @@ -1,10 +1,7 @@ package domain import ( - "context" "time" - - "github.com/zitadel/zitadel/backend/v3/storage/database" ) type OrganizationDomain struct { @@ -34,54 +31,3 @@ type AddOrganizationDomain struct { // It is set by the repository and should not be set by the caller. UpdatedAt time.Time `json:"updatedAt,omitzero" db:"updated_at"` } - -type organizationDomainColumns interface { - domainColumns - // OrgIDColumn returns the column for the org id field. - // `qualified` indicates if the column should be qualified with the table name. - OrgIDColumn(qualified bool) database.Column - // IsVerifiedColumn returns the column for the is verified field. - // `qualified` indicates if the column should be qualified with the table name. - IsVerifiedColumn(qualified bool) database.Column - // ValidationTypeColumn returns the column for the verification type field. - // `qualified` indicates if the column should be qualified with the table name. - ValidationTypeColumn(qualified bool) database.Column -} - -type organizationDomainConditions interface { - domainConditions - // OrgIDCondition returns a filter on the org id field. - OrgIDCondition(orgID string) database.Condition - // IsVerifiedCondition returns a filter on the is verified field. - IsVerifiedCondition(isVerified bool) database.Condition -} - -type organizationDomainChanges interface { - domainChanges - // SetVerified sets the is verified column to true. - SetVerified() database.Change - // SetValidationType sets the verification type column. - // If the domain is already verified, this is a no-op. - SetValidationType(verificationType DomainValidationType) database.Change -} - -type OrganizationDomainRepository interface { - organizationDomainColumns - organizationDomainConditions - organizationDomainChanges - - // Get returns a single domain based on the criteria. - // If no domain is found, it returns an error of type [database.ErrNotFound]. - // If multiple domains are found, it returns an error of type [database.ErrMultipleRows]. - Get(ctx context.Context, opts ...database.QueryOption) (*OrganizationDomain, error) - // List returns a list of domains based on the criteria. - // If no domains are found, it returns an empty slice. - List(ctx context.Context, opts ...database.QueryOption) ([]*OrganizationDomain, error) - - // Add adds a new domain to the organization. - Add(ctx context.Context, domain *AddOrganizationDomain) error - // Update updates an existing domain in the organization. - Update(ctx context.Context, condition database.Condition, changes ...database.Change) (int64, error) - // Remove removes a domain from the organization. - Remove(ctx context.Context, condition database.Condition) (int64, error) -} diff --git a/backend/v3/domain/organization_domain_repository.go b/backend/v3/domain/organization_domain_repository.go new file mode 100644 index 0000000000..212fab0c83 --- /dev/null +++ b/backend/v3/domain/organization_domain_repository.go @@ -0,0 +1,58 @@ +package domain + +import ( + "context" + + "github.com/zitadel/zitadel/backend/v3/storage/database" +) + +type OrganizationDomainRepository interface { + organizationDomainColumns + organizationDomainConditions + organizationDomainChanges + + // Get returns a single domain based on the criteria. + // If no domain is found, it returns an error of type [database.ErrNotFound]. + // If multiple domains are found, it returns an error of type [database.ErrMultipleRows]. + Get(ctx context.Context, opts ...database.QueryOption) (*OrganizationDomain, error) + // List returns a list of domains based on the criteria. + // If no domains are found, it returns an empty slice. + List(ctx context.Context, opts ...database.QueryOption) ([]*OrganizationDomain, error) + + // Add adds a new domain to the organization. + Add(ctx context.Context, domain *AddOrganizationDomain) error + // Update updates an existing domain in the organization. + Update(ctx context.Context, condition database.Condition, changes ...database.Change) (int64, error) + // Remove removes a domain from the organization. + Remove(ctx context.Context, condition database.Condition) (int64, error) +} + +type organizationDomainColumns interface { + domainColumns + // OrgIDColumn returns the column for the org id field. + // `qualified` indicates if the column should be qualified with the table name. + OrgIDColumn(qualified bool) database.Column + // IsVerifiedColumn returns the column for the is verified field. + // `qualified` indicates if the column should be qualified with the table name. + IsVerifiedColumn(qualified bool) database.Column + // ValidationTypeColumn returns the column for the verification type field. + // `qualified` indicates if the column should be qualified with the table name. + ValidationTypeColumn(qualified bool) database.Column +} + +type organizationDomainConditions interface { + domainConditions + // OrgIDCondition returns a filter on the org id field. + OrgIDCondition(orgID string) database.Condition + // IsVerifiedCondition returns a filter on the is verified field. + IsVerifiedCondition(isVerified bool) database.Condition +} + +type organizationDomainChanges interface { + domainChanges + // SetVerified sets the is verified column to true. + SetVerified() database.Change + // SetValidationType sets the verification type column. + // If the domain is already verified, this is a no-op. + SetValidationType(verificationType DomainValidationType) database.Change +} diff --git a/backend/v3/domain/organization_member.go b/backend/v3/domain/organization_member.go new file mode 100644 index 0000000000..fce3d2359b --- /dev/null +++ b/backend/v3/domain/organization_member.go @@ -0,0 +1,45 @@ +package domain + +import "context" + +var _ Commander = (*AddOrgMemberCommand)(nil) + +// AddOrgMemberCommand adds an existing user as an organization member. +type AddOrgMemberCommand struct { + InstanceID string `json:"instanceId"` + OrgID string `json:"orgId"` + UserID string `json:"userId"` + Roles []string `json:"roles"` +} + +// Execute implements [Commander]. +func (a *AddOrgMemberCommand) Execute(ctx context.Context, opts *CommandOpts) (err error) { + panic("unimplemented") +} + +// String implements [Commander]. +func (a *AddOrgMemberCommand) String() string { + return "AddOrgMemberCommand" +} + +var _ Commander = (*CreateOrgMemberCommand)(nil) + +// CreateOrgMemberCommand creates a new user and adds them as an organization member. +type CreateOrgMemberCommand struct{} + +// Execute implements [Commander]. +func (c *CreateOrgMemberCommand) Execute(ctx context.Context, opts *CommandOpts) (err error) { + panic("unimplemented") +} + +// String implements [Commander]. +func (c *CreateOrgMemberCommand) String() string { + panic("unimplemented") +} + +// MemberRepository is a sub repository of the org repository and maybe the instance repository. +type MemberRepository interface { + AddMember(ctx context.Context, orgID, userID string, roles []string) error + SetMemberRoles(ctx context.Context, orgID, userID string, roles []string) error + RemoveMember(ctx context.Context, orgID, userID string) error +} diff --git a/backend/v3/domain/organization_options.go b/backend/v3/domain/organization_options.go new file mode 100644 index 0000000000..d75c6f3949 --- /dev/null +++ b/backend/v3/domain/organization_options.go @@ -0,0 +1,127 @@ +package domain + +import "github.com/zitadel/zitadel/backend/v3/storage/database" + +var _ CreateOrganizationCommandOpts = (*withOrganizationID)(nil) + +type withOrganizationID struct { + id string +} + +func WithOrganizationID(id string) *withOrganizationID { + return &withOrganizationID{ + id: id, + } +} + +func (opt *withOrganizationID) applyOnCreateOrganizationCommand(cmd *CreateOrganizationCommand) { + cmd.ID = opt.id +} + +var _ UpdateOrganizationCommandOpts = (*withOrganizationName)(nil) + +type withOrganizationName struct { + name string +} + +func WithOrganizationName(name string) *withOrganizationName { + return &withOrganizationName{ + name: name, + } +} + +func (opt *withOrganizationName) applyOnUpdateOrganizationCommand(cmd *UpdateOrganizationCommand) { + cmd.changes = append(cmd.changes, cmd.repo.SetName(opt.name)) +} + +var _ OrgsQueryOpts = (*orgByNameQueryOpt)(nil) + +type orgByNameQueryOpt struct { + name string + op database.TextOperation +} + +func WithOrgByNameQuery(op database.TextOperation, name string) *orgByNameQueryOpt { + return &orgByNameQueryOpt{ + name: name, + op: op, + } +} + +func (opt *orgByNameQueryOpt) applyOnOrgsQuery(query *OrgsQuery) { + query.conditions = append(query.conditions, query.repo.NameCondition(opt.op, opt.name)) +} + +var _ OrgsQueryOpts = (*orgByDomainQueryOpt)(nil) + +type orgByDomainQueryOpt struct { + name string + op database.TextOperation +} + +func WithOrgByDomainQuery(op database.TextOperation, name string) *orgByDomainQueryOpt { + return &orgByDomainQueryOpt{ + name: name, + op: op, + } +} + +func (opt *orgByDomainQueryOpt) applyOnOrgsQuery(query *OrgsQuery) { + query.conditions = append(query.conditions, query.domainRepo.DomainCondition(opt.op, opt.name)) +} + +var _ OrgsQueryOpts = (*orgByIDQueryOpt)(nil) + +type orgByIDQueryOpt struct { + id string +} + +func WithOrgByIDQuery(id string) *orgByIDQueryOpt { + return &orgByIDQueryOpt{ + id: id, + } +} + +func (opt *orgByIDQueryOpt) applyOnOrgsQuery(query *OrgsQuery) { + query.conditions = append(query.conditions, query.repo.IDCondition(opt.id)) +} + +var _ OrgsQueryOpts = (*orgByIDQueryOpt)(nil) + +type orgByStateQueryOpt struct { + state OrgState +} + +func WithOrgByStateQuery(state OrgState) *orgByStateQueryOpt { + return &orgByStateQueryOpt{ + state: state, + } +} + +func (opt *orgByStateQueryOpt) applyOnOrgsQuery(query *OrgsQuery) { + query.conditions = append(query.conditions, query.repo.StateCondition(opt.state)) +} + +var _ OrgsQueryOpts = (*orgByIDQueryOpt)(nil) + +type orgQuerySortingColumnOpt struct { + getColumn func(query *OrgsQuery) database.Column +} + +func WithOrgQuerySortingColumn(getColumn func(query *OrgsQuery) database.Column) *orgQuerySortingColumnOpt { + return &orgQuerySortingColumnOpt{ + getColumn: getColumn, + } +} + +func OrderOrgsByCreationDate(query *OrgsQuery) database.Column { + return query.repo.CreatedAtColumn(true) +} + +func OrderOrgsByName(query *OrgsQuery) database.Column { + return query.repo.NameColumn(true) +} + +func (opt *orgQuerySortingColumnOpt) applyOnOrgsQuery(query *OrgsQuery) { + query.pagination.OrderColumns = append(query.pagination.OrderColumns, opt.getColumn(query)) +} diff --git a/backend/v3/domain/organization_repository.go b/backend/v3/domain/organization_repository.go new file mode 100644 index 0000000000..ce73350876 --- /dev/null +++ b/backend/v3/domain/organization_repository.go @@ -0,0 +1,75 @@ +package domain + +import ( + "context" + + "github.com/zitadel/zitadel/backend/v3/storage/database" +) + +// OrganizationRepository is the interface for the instance repository. +type OrganizationRepository interface { + organizationColumns + organizationConditions + organizationChanges + + Get(ctx context.Context, opts ...database.QueryOption) (*Organization, error) + List(ctx context.Context, opts ...database.QueryOption) ([]*Organization, error) + + Create(ctx context.Context, instance *Organization) error + Update(ctx context.Context, id OrgIdentifierCondition, instance_id string, changes ...database.Change) (int64, error) + Delete(ctx context.Context, id OrgIdentifierCondition, instance_id string) (int64, error) + + // Domains returns the domain sub repository for the organization. + // If shouldLoad is true, the domains will be loaded from the database and written to the [Organization].Domains field. + // If shouldLoad is set to true once, the Domains field will be set even if shouldLoad is false in the future. + Domains(shouldLoad bool) OrganizationDomainRepository +} + +// OrgIdentifierCondition is used to help specify a single Organization, +// it will either be used as the organization ID or organization name, +// as organizations can be identified either using (instanceID + ID) OR (instanceID + name) +type OrgIdentifierCondition interface { + database.Condition +} + +// organizationColumns define all the columns of the instance table. +type organizationColumns interface { + // IDColumn returns the column for the id field. + // `qualified` indicates if the column should be qualified with the table name. + IDColumn(qualified bool) database.Column + // NameColumn returns the column for the name field. + // `qualified` indicates if the column should be qualified with the table name. + NameColumn(qualified bool) database.Column + // InstanceIDColumn returns the column for the default org id field + // `qualified` indicates if the column should be qualified with the table name. + InstanceIDColumn(qualified bool) database.Column + // StateColumn returns the column for the name field. + // `qualified` indicates if the column should be qualified with the table name. + StateColumn(qualified bool) database.Column + // CreatedAtColumn returns the column for the created at field. + // `qualified` indicates if the column should be qualified with the table name. + CreatedAtColumn(qualified bool) database.Column + // UpdatedAtColumn returns the column for the updated at field. + // `qualified` indicates if the column should be qualified with the table name. + UpdatedAtColumn(qualified bool) database.Column +} + +// organizationConditions define all the conditions for the instance table. +type organizationConditions interface { + // IDCondition returns an equal filter on the id field. + IDCondition(id string) OrgIdentifierCondition + // NameCondition returns a filter on the name field. + NameCondition(name string) OrgIdentifierCondition + // InstanceIDCondition returns a filter on the instance id field. + InstanceIDCondition(instanceID string) database.Condition + // StateCondition returns a filter on the name field. + StateCondition(state OrgState) database.Condition +} + +// organizationChanges define all the changes for the instance table. +type organizationChanges interface { + // SetName sets the name column. + SetName(name string) database.Change + // SetState sets the name column. + SetState(state OrgState) database.Change +} diff --git a/backend/v3/domain/query.go b/backend/v3/domain/query.go new file mode 100644 index 0000000000..2fd835c945 --- /dev/null +++ b/backend/v3/domain/query.go @@ -0,0 +1,17 @@ +package domain + +import "github.com/zitadel/zitadel/backend/v3/storage/database" + +type Pagination struct { + Limit uint32 + Offset uint32 + Ascending bool + OrderColumns database.Columns +} + +// applyOnOrgsQuery implements OrgsQueryOpts. +func (p Pagination) applyOnOrgsQuery(query *OrgsQuery) { + query.pagination = p +} + +var _ OrgsQueryOpts = (*Pagination)(nil) diff --git a/backend/v3/storage/database/query.go b/backend/v3/storage/database/query.go index abc66d9377..8025e555ea 100644 --- a/backend/v3/storage/database/query.go +++ b/backend/v3/storage/database/query.go @@ -10,9 +10,10 @@ func WithCondition(condition Condition) QueryOption { } // WithOrderBy sets the columns to order the results by. -func WithOrderBy(orderBy ...Column) QueryOption { +func WithOrderBy(descending bool, orderBy ...Column) QueryOption { return func(opts *QueryOpts) { opts.OrderBy = orderBy + opts.OrderByDescending = descending } } @@ -69,6 +70,9 @@ type QueryOpts struct { // OrderBy is the columns to order the results by. // It is used to build the ORDER BY clause of the SQL statement. OrderBy Columns + // OrderByDescending indicates if the results should be ordered in descending order. + // default is ascending order. + OrderByDescending bool // Limit is the maximum number of results to return. // It is used to build the LIMIT clause of the SQL statement. Limit uint32 @@ -105,7 +109,15 @@ func (opts *QueryOpts) WriteOrderBy(builder *StatementBuilder) { return } builder.WriteString(" ORDER BY ") - opts.OrderBy.Write(builder) + for i, col := range opts.OrderBy { + if i > 0 { + builder.WriteString(", ") + } + col.Write(builder) + if opts.OrderByDescending { + builder.WriteString(" DESC") + } + } } func (opts *QueryOpts) WriteLimit(builder *StatementBuilder) { diff --git a/backend/v3/storage/database/repository/instance_test.go b/backend/v3/storage/database/repository/instance_test.go index e40f416099..59bd82ad5b 100644 --- a/backend/v3/storage/database/repository/instance_test.go +++ b/backend/v3/storage/database/repository/instance_test.go @@ -538,7 +538,7 @@ func TestListInstance(t *testing.T) { // check instance values returnedInstances, err := instanceRepo.List(ctx, database.WithCondition(condition), - database.WithOrderBy(instanceRepo.CreatedAtColumn(true)), + database.WithOrderBy(false, instanceRepo.CreatedAtColumn(true)), ) require.NoError(t, err) if tt.noInstanceReturned { diff --git a/backend/v3/storage/database/repository/org_test.go b/backend/v3/storage/database/repository/org_test.go index 26b7add8d9..e8e2183715 100644 --- a/backend/v3/storage/database/repository/org_test.go +++ b/backend/v3/storage/database/repository/org_test.go @@ -785,7 +785,7 @@ func TestListOrganization(t *testing.T) { // check organization values returnedOrgs, err := organizationRepo.List(ctx, database.WithCondition(condition), - database.WithOrderBy(organizationRepo.CreatedAtColumn(true)), + database.WithOrderBy(false, organizationRepo.CreatedAtColumn(true)), ) require.NoError(t, err) if tt.noOrganizationReturned {