Merge branch 'main' into integration-tests

This commit is contained in:
Tim Möhlmann
2023-04-27 12:47:35 +03:00
104 changed files with 5089 additions and 749 deletions

View File

@@ -14,11 +14,11 @@ const (
authenticated = "authenticated"
)
func CheckUserAuthorization(ctx context.Context, req interface{}, token, orgID string, verifier *TokenVerifier, authConfig Config, requiredAuthOption Option, method string) (ctxSetter func(context.Context) context.Context, err error) {
func CheckUserAuthorization(ctx context.Context, req interface{}, token, orgIDHeader string, verifier *TokenVerifier, authConfig Config, requiredAuthOption Option, method string) (ctxSetter func(context.Context) context.Context, err error) {
ctx, span := tracing.NewServerInterceptorSpan(ctx)
defer func() { span.EndWithError(err) }()
ctxData, err := VerifyTokenAndCreateCtxData(ctx, token, orgID, verifier, method)
ctxData, err := VerifyTokenAndCreateCtxData(ctx, token, orgIDHeader, verifier, method)
if err != nil {
return nil, err
}
@@ -29,7 +29,7 @@ func CheckUserAuthorization(ctx context.Context, req interface{}, token, orgID s
}, nil
}
requestedPermissions, allPermissions, err := getUserMethodPermissions(ctx, verifier, requiredAuthOption.Permission, authConfig, ctxData)
requestedPermissions, allPermissions, err := getUserPermissions(ctx, verifier, requiredAuthOption.Permission, authConfig.RolePermissionMappings, ctxData, ctxData.OrgID)
if err != nil {
return nil, err
}
@@ -110,18 +110,6 @@ func HasGlobalPermission(perms []string) bool {
return false
}
func HasGlobalExplicitPermission(perms []string, permToCheck string) bool {
for _, perm := range perms {
p, ctxID := SplitPermission(perm)
if p == permToCheck {
if ctxID == "" {
return true
}
}
}
return false
}
func GetAllPermissionCtxIDs(perms []string) []string {
ctxIDs := make([]string, 0)
for _, perm := range perms {
@@ -132,16 +120,3 @@ func GetAllPermissionCtxIDs(perms []string) []string {
}
return ctxIDs
}
func GetExplicitPermissionCtxIDs(perms []string, searchPerm string) []string {
ctxIDs := make([]string, 0)
for _, perm := range perms {
p, ctxID := SplitPermission(perm)
if p == searchPerm {
if ctxID != "" {
ctxIDs = append(ctxIDs, ctxID)
}
}
}
return ctxIDs
}

View File

@@ -14,11 +14,11 @@ type MethodMapping map[string]Option
type Option struct {
Permission string
CheckParam string
Feature string
AllowSelf bool
}
func (a *Config) getPermissionsFromRole(role string) []string {
for _, roleMap := range a.RolePermissionMappings {
func getPermissionsFromRole(rolePermissionMappings []RoleMapping, role string) []string {
for _, roleMap := range rolePermissionMappings {
if roleMap.Role == role {
return roleMap.Permissions
}

View File

@@ -7,7 +7,28 @@ import (
"github.com/zitadel/zitadel/internal/telemetry/tracing"
)
func getUserMethodPermissions(ctx context.Context, t *TokenVerifier, requiredPerm string, authConfig Config, ctxData CtxData) (requestedPermissions, allPermissions []string, err error) {
func CheckPermission(ctx context.Context, resolver MembershipsResolver, roleMappings []RoleMapping, permission, orgID, resourceID string, allowSelf bool) (err error) {
ctxData := GetCtxData(ctx)
if allowSelf && ctxData.UserID == resourceID {
return nil
}
requestedPermissions, _, err := getUserPermissions(ctx, resolver, permission, roleMappings, ctxData, orgID)
if err != nil {
return err
}
_, userPermissionSpan := tracing.NewNamedSpan(ctx, "checkUserPermissions")
err = checkUserResourcePermissions(requestedPermissions, resourceID)
userPermissionSpan.EndWithError(err)
if err != nil {
return err
}
return nil
}
// getUserPermissions retrieves the memberships of the authenticated user (on instance and provided organisation level),
// and maps them to permissions. It will return the requested permission(s) and all other granted permissions separately.
func getUserPermissions(ctx context.Context, resolver MembershipsResolver, requiredPerm string, roleMappings []RoleMapping, ctxData CtxData, orgID string) (requestedPermissions, allPermissions []string, err error) {
ctx, span := tracing.NewSpan(ctx)
defer func() { span.EndWithError(err) }()
@@ -16,13 +37,13 @@ func getUserMethodPermissions(ctx context.Context, t *TokenVerifier, requiredPer
}
ctx = context.WithValue(ctx, dataKey, ctxData)
memberships, err := t.SearchMyMemberships(ctx)
memberships, err := resolver.SearchMyMemberships(ctx, orgID)
if err != nil {
return nil, nil, err
}
if len(memberships) == 0 {
err = retry(func() error {
memberships, err = t.SearchMyMemberships(ctx)
memberships, err = resolver.SearchMyMemberships(ctx, orgID)
if err != nil {
return err
}
@@ -35,24 +56,56 @@ func getUserMethodPermissions(ctx context.Context, t *TokenVerifier, requiredPer
return nil, nil, nil
}
}
requestedPermissions, allPermissions = mapMembershipsToPermissions(requiredPerm, memberships, authConfig)
requestedPermissions, allPermissions = mapMembershipsToPermissions(requiredPerm, memberships, roleMappings)
return requestedPermissions, allPermissions, nil
}
func mapMembershipsToPermissions(requiredPerm string, memberships []*Membership, authConfig Config) (requestPermissions, allPermissions []string) {
// checkUserResourcePermissions checks that if a user i granted either the requested permission globally (project.write)
// or the specific resource (project.write:123)
func checkUserResourcePermissions(userPerms []string, resourceID string) error {
if len(userPerms) == 0 {
return errors.ThrowPermissionDenied(nil, "AUTH-AWfge", "No matching permissions found")
}
if resourceID == "" {
return nil
}
if HasGlobalPermission(userPerms) {
return nil
}
if hasContextResourcePermission(userPerms, resourceID) {
return nil
}
return errors.ThrowPermissionDenied(nil, "AUTH-Swrgg2", "No matching permissions found")
}
func hasContextResourcePermission(permissions []string, resourceID string) bool {
for _, perm := range permissions {
_, ctxID := SplitPermission(perm)
if resourceID == ctxID {
return true
}
}
return false
}
func mapMembershipsToPermissions(requiredPerm string, memberships []*Membership, roleMappings []RoleMapping) (requestPermissions, allPermissions []string) {
requestPermissions = make([]string, 0)
allPermissions = make([]string, 0)
for _, membership := range memberships {
requestPermissions, allPermissions = mapMembershipToPerm(requiredPerm, membership, authConfig, requestPermissions, allPermissions)
requestPermissions, allPermissions = mapMembershipToPerm(requiredPerm, membership, roleMappings, requestPermissions, allPermissions)
}
return requestPermissions, allPermissions
}
func mapMembershipToPerm(requiredPerm string, membership *Membership, authConfig Config, requestPermissions, allPermissions []string) ([]string, []string) {
func mapMembershipToPerm(requiredPerm string, membership *Membership, roleMappings []RoleMapping, requestPermissions, allPermissions []string) ([]string, []string) {
roleNames, roleContextID := roleWithContext(membership)
for _, roleName := range roleNames {
perms := authConfig.getPermissionsFromRole(roleName)
perms := getPermissionsFromRole(roleMappings, roleName)
for _, p := range perms {
permWithCtx := addRoleContextIDToPerm(p, roleContextID)

View File

@@ -18,7 +18,7 @@ type testVerifier struct {
func (v *testVerifier) VerifyAccessToken(ctx context.Context, token, clientID, projectID string) (string, string, string, string, string, error) {
return "userID", "agentID", "clientID", "de", "orgID", nil
}
func (v *testVerifier) SearchMyMemberships(ctx context.Context) ([]*Membership, error) {
func (v *testVerifier) SearchMyMemberships(ctx context.Context, orgID string) ([]*Membership, error) {
return v.memberships, nil
}
@@ -46,7 +46,7 @@ func equalStringArray(a, b []string) bool {
return true
}
func Test_GetUserMethodPermissions(t *testing.T) {
func Test_GetUserPermissions(t *testing.T) {
type args struct {
ctxData CtxData
verifier *TokenVerifier
@@ -139,7 +139,7 @@ func Test_GetUserMethodPermissions(t *testing.T) {
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, perms, err := getUserMethodPermissions(context.Background(), tt.args.verifier, tt.args.requiredPerm, tt.args.authConfig, tt.args.ctxData)
_, perms, err := getUserPermissions(context.Background(), tt.args.verifier, tt.args.requiredPerm, tt.args.authConfig.RolePermissionMappings, tt.args.ctxData, tt.args.ctxData.OrgID)
if tt.wantErr && err == nil {
t.Errorf("got wrong result, should get err: actual: %v ", err)
@@ -295,7 +295,7 @@ func Test_MapMembershipToPermissions(t *testing.T) {
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
requestPerms, allPerms := mapMembershipsToPermissions(tt.args.requiredPerm, tt.args.membership, tt.args.authConfig)
requestPerms, allPerms := mapMembershipsToPermissions(tt.args.requiredPerm, tt.args.membership, tt.args.authConfig.RolePermissionMappings)
if !equalStringArray(requestPerms, tt.requestPerms) {
t.Errorf("got wrong requestPerms, expecting: %v, actual: %v ", tt.requestPerms, requestPerms)
}
@@ -435,7 +435,7 @@ func Test_MapMembershipToPerm(t *testing.T) {
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
requestPerms, allPerms := mapMembershipToPerm(tt.args.requiredPerm, tt.args.membership, tt.args.authConfig, tt.args.requestPerms, tt.args.allPerms)
requestPerms, allPerms := mapMembershipToPerm(tt.args.requiredPerm, tt.args.membership, tt.args.authConfig.RolePermissionMappings, tt.args.requestPerms, tt.args.allPerms)
if !equalStringArray(requestPerms, tt.requestPerms) {
t.Errorf("got wrong requestPerms, expecting: %v, actual: %v ", tt.requestPerms, requestPerms)
}
@@ -519,3 +519,109 @@ func Test_ExistisPerm(t *testing.T) {
})
}
}
func Test_CheckUserResourcePermissions(t *testing.T) {
type args struct {
perms []string
resourceID string
}
tests := []struct {
name string
args args
wantErr bool
}{
{
name: "no permissions",
args: args{
perms: []string{},
resourceID: "",
},
wantErr: true,
},
{
name: "has permission and no context requested",
args: args{
perms: []string{"project.read"},
resourceID: "",
},
wantErr: false,
},
{
name: "context requested and has global permission",
args: args{
perms: []string{"project.read", "project.read:1"},
resourceID: "Test",
},
wantErr: false,
},
{
name: "context requested and has specific permission",
args: args{
perms: []string{"project.read:Test"},
resourceID: "Test",
},
wantErr: false,
},
{
name: "context requested and has no permission",
args: args{
perms: []string{"project.read:Test"},
resourceID: "Hodor",
},
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := checkUserResourcePermissions(tt.args.perms, tt.args.resourceID)
if tt.wantErr && err == nil {
t.Errorf("got wrong result, should get err: actual: %v ", err)
}
if !tt.wantErr && err != nil {
t.Errorf("shouldn't get err: %v ", err)
}
if tt.wantErr && !caos_errs.IsPermissionDenied(err) {
t.Errorf("got wrong err: %v ", err)
}
})
}
}
func Test_HasContextResourcePermission(t *testing.T) {
type args struct {
perms []string
resourceID string
}
tests := []struct {
name string
args args
result bool
}{
{
name: "existing context permission",
args: args{
perms: []string{"test:wrong", "test:right"},
resourceID: "right",
},
result: true,
},
{
name: "not existing context permission",
args: args{
perms: []string{"test:wrong", "test:wrong2"},
resourceID: "test",
},
result: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := hasContextResourcePermission(tt.args.perms, tt.args.resourceID)
if result != tt.result {
t.Errorf("got wrong result, expecting: %v, actual: %v ", tt.result, result)
}
})
}
}

View File

@@ -27,10 +27,14 @@ type TokenVerifier struct {
systemJWTProfile op.JWTProfileVerifier
}
type MembershipsResolver interface {
SearchMyMemberships(ctx context.Context, orgID string) ([]*Membership, error)
}
type authZRepo interface {
VerifyAccessToken(ctx context.Context, token, verifierClientID, projectID string) (userID, agentID, clientID, prefLang, resourceOwner string, err error)
VerifierClientID(ctx context.Context, name string) (clientID, projectID string, err error)
SearchMyMemberships(ctx context.Context) ([]*Membership, error)
SearchMyMemberships(ctx context.Context, orgID string) ([]*Membership, error)
ProjectIDAndOriginsByClientID(ctx context.Context, clientID string) (projectID string, origins []string, err error)
ExistsOrg(ctx context.Context, orgID string) error
}
@@ -127,10 +131,10 @@ func (v *TokenVerifier) RegisterServer(appName, methodPrefix string, mappings Me
}
}
func (v *TokenVerifier) SearchMyMemberships(ctx context.Context) (_ []*Membership, err error) {
func (v *TokenVerifier) SearchMyMemberships(ctx context.Context, orgID string) (_ []*Membership, err error) {
ctx, span := tracing.NewSpan(ctx)
defer func() { span.EndWithError(err) }()
return v.authZRepo.SearchMyMemberships(ctx)
return v.authZRepo.SearchMyMemberships(ctx, orgID)
}
func (v *TokenVerifier) ProjectIDAndOriginsByClientID(ctx context.Context, clientID string) (_ string, _ []string, err error) {

View File

@@ -71,7 +71,7 @@ func (s *Server) AppName() string {
}
func (s *Server) MethodPrefix() string {
return admin.AdminService_MethodPrefix
return admin.AdminService_ServiceDesc.ServiceName
}
func (s *Server) AuthMethods() authz.MethodMapping {

View File

@@ -69,7 +69,7 @@ func (s *Server) AppName() string {
}
func (s *Server) MethodPrefix() string {
return auth.AuthService_MethodPrefix
return auth.AuthService_ServiceDesc.ServiceName
}
func (s *Server) AuthMethods() authz.MethodMapping {

View File

@@ -63,7 +63,7 @@ func (s *Server) AppName() string {
}
func (s *Server) MethodPrefix() string {
return management.ManagementService_MethodPrefix
return management.ManagementService_ServiceDesc.ServiceName
}
func (s *Server) AuthMethods() authz.MethodMapping {

View File

@@ -210,17 +210,14 @@ func (s *Server) BulkRemoveUserMetadata(ctx context.Context, req *mgmt_pb.BulkRe
}
func (s *Server) AddHumanUser(ctx context.Context, req *mgmt_pb.AddHumanUserRequest) (*mgmt_pb.AddHumanUserResponse, error) {
details, err := s.command.AddHuman(ctx, authz.GetCtxData(ctx).OrgID, AddHumanUserRequestToAddHuman(req))
human := AddHumanUserRequestToAddHuman(req)
err := s.command.AddHuman(ctx, authz.GetCtxData(ctx).OrgID, human, true)
if err != nil {
return nil, err
}
return &mgmt_pb.AddHumanUserResponse{
UserId: details.ID,
Details: obj_grpc.AddToDetailsPb(
details.Sequence,
details.EventDate,
details.ResourceOwner,
),
UserId: human.ID,
Details: obj_grpc.DomainToAddDetailsPb(human.Details),
}, nil
}

View File

@@ -0,0 +1,19 @@
package object
import (
"google.golang.org/protobuf/types/known/timestamppb"
"github.com/zitadel/zitadel/internal/domain"
object "github.com/zitadel/zitadel/pkg/grpc/object/v2alpha"
)
func DomainToDetailsPb(objectDetail *domain.ObjectDetails) *object.Details {
details := &object.Details{
Sequence: objectDetail.Sequence,
ResourceOwner: objectDetail.ResourceOwner,
}
if !objectDetail.EventDate.IsZero() {
details.ChangeDate = timestamppb.New(objectDetail.EventDate)
}
return details
}

View File

@@ -34,6 +34,9 @@ func authorize(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo,
}
orgID := grpc_util.GetHeader(authCtx, http.ZitadelOrgID)
if o, ok := req.(AuthContext); ok {
orgID = o.AuthContext()
}
ctxSetter, err := authz.CheckUserAuthorization(authCtx, req, authToken, orgID, verifier, authConfig, authOpt, info.FullMethod)
if err != nil {
@@ -42,3 +45,7 @@ func authorize(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo,
span.End()
return handler(ctxSetter(ctx), req)
}
type AuthContext interface {
AuthContext() string
}

View File

@@ -24,7 +24,7 @@ type verifierMock struct{}
func (v *verifierMock) VerifyAccessToken(ctx context.Context, token, clientID, projectID string) (string, string, string, string, string, error) {
return "", "", "", "", "", nil
}
func (v *verifierMock) SearchMyMemberships(ctx context.Context) ([]*authz.Membership, error) {
func (v *verifierMock) SearchMyMemberships(ctx context.Context, orgID string) ([]*authz.Membership, error) {
return nil, nil
}

View File

@@ -50,13 +50,13 @@ func CreateServer(
middleware.MetricsHandler(metricTypes, grpc_api.Probes...),
middleware.NoCacheInterceptor(),
middleware.ErrorHandler(),
middleware.InstanceInterceptor(queries, hostHeaderName, system_pb.SystemService_MethodPrefix, healthpb.Health_ServiceDesc.ServiceName),
middleware.InstanceInterceptor(queries, hostHeaderName, system_pb.SystemService_ServiceDesc.ServiceName, healthpb.Health_ServiceDesc.ServiceName),
middleware.AccessStorageInterceptor(accessSvc),
middleware.AuthorizationInterceptor(verifier, authConfig),
middleware.TranslationHandler(),
middleware.ValidationHandler(),
middleware.ServiceHandler(),
middleware.QuotaExhaustedInterceptor(accessSvc, system_pb.SystemService_MethodPrefix),
middleware.QuotaExhaustedInterceptor(accessSvc, system_pb.SystemService_ServiceDesc.ServiceName),
),
),
}

View File

@@ -4,7 +4,6 @@ import (
"google.golang.org/grpc"
"github.com/zitadel/zitadel/internal/admin/repository"
"github.com/zitadel/zitadel/internal/admin/repository/eventsourcing"
"github.com/zitadel/zitadel/internal/api/authz"
"github.com/zitadel/zitadel/internal/api/grpc/server"
@@ -60,7 +59,7 @@ func (s *Server) AppName() string {
}
func (s *Server) MethodPrefix() string {
return system.SystemService_MethodPrefix
return system.SystemService_ServiceDesc.ServiceName
}
func (s *Server) AuthMethods() authz.MethodMapping {

View File

@@ -0,0 +1,65 @@
package user
import (
"context"
"google.golang.org/protobuf/types/known/timestamppb"
"github.com/zitadel/zitadel/internal/domain"
caos_errs "github.com/zitadel/zitadel/internal/errors"
object "github.com/zitadel/zitadel/pkg/grpc/object/v2alpha"
user "github.com/zitadel/zitadel/pkg/grpc/user/v2alpha"
)
func (s *Server) SetEmail(ctx context.Context, req *user.SetEmailRequest) (resp *user.SetEmailResponse, err error) {
var resourceOwner string // TODO: check if still needed
var email *domain.Email
switch v := req.GetVerification().(type) {
case *user.SetEmailRequest_SendCode:
email, err = s.command.ChangeUserEmailURLTemplate(ctx, req.GetUserId(), resourceOwner, req.GetEmail(), s.userCodeAlg, v.SendCode.GetUrlTemplate())
case *user.SetEmailRequest_ReturnCode:
email, err = s.command.ChangeUserEmailReturnCode(ctx, req.GetUserId(), resourceOwner, req.GetEmail(), s.userCodeAlg)
case *user.SetEmailRequest_IsVerified:
if v.IsVerified {
email, err = s.command.ChangeUserEmailVerified(ctx, req.GetUserId(), resourceOwner, req.GetEmail())
} else {
email, err = s.command.ChangeUserEmail(ctx, req.GetUserId(), resourceOwner, req.GetEmail(), s.userCodeAlg)
}
case nil:
email, err = s.command.ChangeUserEmail(ctx, req.GetUserId(), resourceOwner, req.GetEmail(), s.userCodeAlg)
default:
err = caos_errs.ThrowUnimplementedf(nil, "USERv2-Ahng0", "verification oneOf %T in method SetEmail not implemented", v)
}
if err != nil {
return nil, err
}
return &user.SetEmailResponse{
Details: &object.Details{
Sequence: email.Sequence,
ChangeDate: timestamppb.New(email.ChangeDate),
ResourceOwner: email.ResourceOwner,
},
VerificationCode: email.PlainCode,
}, nil
}
func (s *Server) VerifyEmail(ctx context.Context, req *user.VerifyEmailRequest) (*user.VerifyEmailResponse, error) {
details, err := s.command.VerifyUserEmail(ctx,
req.GetUserId(),
"", // TODO: check if still needed
req.GetVerificationCode(),
s.userCodeAlg,
)
if err != nil {
return nil, err
}
return &user.VerifyEmailResponse{
Details: &object.Details{
Sequence: details.Sequence,
ChangeDate: timestamppb.New(details.EventDate),
ResourceOwner: details.ResourceOwner,
},
}, nil
}

View File

@@ -6,27 +6,27 @@ import (
"github.com/zitadel/zitadel/internal/api/authz"
"github.com/zitadel/zitadel/internal/api/grpc/server"
"github.com/zitadel/zitadel/internal/command"
"github.com/zitadel/zitadel/internal/crypto"
"github.com/zitadel/zitadel/internal/query"
"github.com/zitadel/zitadel/pkg/grpc/user/v2alpha"
user "github.com/zitadel/zitadel/pkg/grpc/user/v2alpha"
)
var _ user.UserServiceServer = (*Server)(nil)
type Server struct {
user.UnimplementedUserServiceServer
command *command.Commands
query *query.Queries
command *command.Commands
query *query.Queries
userCodeAlg crypto.EncryptionAlgorithm
}
type Config struct{}
func CreateServer(
command *command.Commands,
query *query.Queries,
) *Server {
func CreateServer(command *command.Commands, query *query.Queries, userCodeAlg crypto.EncryptionAlgorithm) *Server {
return &Server{
command: command,
query: query,
command: command,
query: query,
userCodeAlg: userCodeAlg,
}
}

View File

@@ -1,55 +0,0 @@
package user
import (
"context"
"github.com/zitadel/zitadel/internal/api/authz"
"github.com/zitadel/zitadel/internal/errors"
"github.com/zitadel/zitadel/pkg/grpc/user/v2alpha"
)
func (s *Server) TestGet(ctx context.Context, req *user.TestGetRequest) (*user.TestGetResponse, error) {
return &user.TestGetResponse{
Ctx: req.Ctx.String(),
}, nil
}
func (s *Server) TestPost(ctx context.Context, req *user.TestPostRequest) (*user.TestPostResponse, error) {
return &user.TestPostResponse{
Ctx: req.Ctx.String(),
}, nil
}
func (s *Server) TestAuth(ctx context.Context, req *user.TestAuthRequest) (*user.TestAuthResponse, error) {
reqCtx, err := authDemo(ctx, req.Ctx)
if err != nil {
return nil, err
}
return &user.TestAuthResponse{
User: &user.User{Id: authz.GetCtxData(ctx).UserID},
Ctx: reqCtx,
}, nil
}
func authDemo(ctx context.Context, reqCtx *user.Context) (*user.Context, error) {
ro := authz.GetCtxData(ctx).ResourceOwner
if reqCtx == nil {
return &user.Context{Ctx: &user.Context_OrgId{OrgId: ro}}, nil
}
switch c := reqCtx.Ctx.(type) {
case *user.Context_OrgId:
if c.OrgId == ro {
return reqCtx, nil
}
return nil, errors.ThrowPermissionDenied(nil, "USER-dg4g", "Errors.User.NotAllowedOrg")
case *user.Context_OrgDomain:
if c.OrgDomain == "forbidden.com" {
return nil, errors.ThrowPermissionDenied(nil, "USER-SDg4g", "Errors.User.NotAllowedOrg")
}
return reqCtx, nil
case *user.Context_Instance:
return reqCtx, nil
default:
return reqCtx, nil
}
}

View File

@@ -0,0 +1,112 @@
package user
import (
"context"
"io"
"golang.org/x/text/language"
"github.com/zitadel/zitadel/internal/api/authz"
"github.com/zitadel/zitadel/internal/api/grpc/object/v2"
"github.com/zitadel/zitadel/internal/command"
"github.com/zitadel/zitadel/internal/domain"
"github.com/zitadel/zitadel/internal/errors"
"github.com/zitadel/zitadel/pkg/grpc/user/v2alpha"
)
func (s *Server) AddHumanUser(ctx context.Context, req *user.AddHumanUserRequest) (_ *user.AddHumanUserResponse, err error) {
human, err := addUserRequestToAddHuman(req)
if err != nil {
return nil, err
}
orgID := req.GetOrganisation().GetOrgId()
if orgID == "" {
orgID = authz.GetCtxData(ctx).OrgID
}
err = s.command.AddHuman(ctx, orgID, human, false)
if err != nil {
return nil, err
}
return &user.AddHumanUserResponse{
UserId: human.ID,
Details: object.DomainToDetailsPb(human.Details),
EmailCode: human.EmailCode,
}, nil
}
func addUserRequestToAddHuman(req *user.AddHumanUserRequest) (*command.AddHuman, error) {
username := req.GetUsername()
if username == "" {
username = req.GetEmail().GetEmail()
}
var urlTemplate string
if req.GetEmail().GetSendCode() != nil {
urlTemplate = req.GetEmail().GetSendCode().GetUrlTemplate()
// test the template execution so the async notification will not fail because of it and the user won't realize
if err := domain.RenderConfirmURLTemplate(io.Discard, urlTemplate, req.GetUserId(), "code", "orgID"); err != nil {
return nil, err
}
}
bcryptedPassword, err := hashedPasswordToCommand(req.GetHashedPassword())
if err != nil {
return nil, err
}
passwordChangeRequired := req.GetPassword().GetChangeRequired() || req.GetHashedPassword().GetChangeRequired()
metadata := make([]*command.AddMetadataEntry, len(req.Metadata))
for i, metadataEntry := range req.Metadata {
metadata[i] = &command.AddMetadataEntry{
Key: metadataEntry.GetKey(),
Value: metadataEntry.GetValue(),
}
}
return &command.AddHuman{
ID: req.GetUserId(),
Username: username,
FirstName: req.GetProfile().GetFirstName(),
LastName: req.GetProfile().GetLastName(),
NickName: req.GetProfile().GetNickName(),
DisplayName: req.GetProfile().GetDisplayName(),
Email: command.Email{
Address: domain.EmailAddress(req.GetEmail().GetEmail()),
Verified: req.GetEmail().GetIsVerified(),
ReturnCode: req.GetEmail().GetReturnCode() != nil,
URLTemplate: urlTemplate,
},
PreferredLanguage: language.Make(req.GetProfile().GetPreferredLanguage()),
Gender: genderToDomain(req.GetProfile().GetGender()),
Phone: command.Phone{}, // TODO: add as soon as possible
Password: req.GetPassword().GetPassword(),
BcryptedPassword: bcryptedPassword,
PasswordChangeRequired: passwordChangeRequired,
Passwordless: false,
ExternalIDP: false,
Register: false,
Metadata: metadata,
}, nil
}
func genderToDomain(gender user.Gender) domain.Gender {
switch gender {
case user.Gender_GENDER_UNSPECIFIED:
return domain.GenderUnspecified
case user.Gender_GENDER_FEMALE:
return domain.GenderFemale
case user.Gender_GENDER_MALE:
return domain.GenderMale
case user.Gender_GENDER_DIVERSE:
return domain.GenderDiverse
default:
return domain.GenderUnspecified
}
}
func hashedPasswordToCommand(hashed *user.HashedPassword) (string, error) {
if hashed == nil {
return "", nil
}
// we currently only handle bcrypt
if hashed.GetAlgorithm() != "bcrypt" {
return "", errors.ThrowInvalidArgument(nil, "USER-JDk4t", "Errors.InvalidArgument")
}
return hashed.GetHash(), nil
}

View File

@@ -0,0 +1,80 @@
package user
import (
"errors"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
caos_errs "github.com/zitadel/zitadel/internal/errors"
user "github.com/zitadel/zitadel/pkg/grpc/user/v2alpha"
)
func Test_hashedPasswordToCommand(t *testing.T) {
type args struct {
hashed *user.HashedPassword
}
type res struct {
want string
err func(error) bool
}
tests := []struct {
name string
args args
res res
}{
{
"not hashed",
args{
hashed: nil,
},
res{
"",
nil,
},
},
{
"hashed, not bcrypt",
args{
hashed: &user.HashedPassword{
Hash: "hash",
Algorithm: "custom",
},
},
res{
"",
func(err error) bool {
return errors.Is(err, caos_errs.ThrowInvalidArgument(nil, "USER-JDk4t", "Errors.InvalidArgument"))
},
},
},
{
"hashed, bcrypt",
args{
hashed: &user.HashedPassword{
Hash: "hash",
Algorithm: "bcrypt",
},
},
res{
"hash",
nil,
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := hashedPasswordToCommand(tt.args.hashed)
if tt.res.err == nil {
require.NoError(t, err)
}
if tt.res.err != nil && !tt.res.err(err) {
t.Errorf("got wrong err: %v ", err)
}
if tt.res.err == nil {
assert.Equal(t, tt.res.want, got)
}
})
}
}

View File

@@ -176,7 +176,7 @@ func (o *OPStorage) lockAndGenerateSigningKeyPair(ctx context.Context, algorithm
if errors.IsErrorAlreadyExists(err) {
return nil
}
logging.OnError(err).Warn("initial lock failed")
logging.OnError(err).Debug("initial lock failed")
return err
}

View File

@@ -123,7 +123,7 @@ func (p *Storage) lockAndGenerateCertificateAndKey(ctx context.Context, usage do
if errors.IsErrorAlreadyExists(err) {
return nil
}
logging.OnError(err).Warn("initial lock failed")
logging.OnError(err).Debug("initial lock failed")
return err
}

View File

@@ -12,17 +12,17 @@ type UserMembershipRepo struct {
Queries *query.Queries
}
func (repo *UserMembershipRepo) SearchMyMemberships(ctx context.Context) (_ []*authz.Membership, err error) {
func (repo *UserMembershipRepo) SearchMyMemberships(ctx context.Context, orgID string) (_ []*authz.Membership, err error) {
ctx, span := tracing.NewSpan(ctx)
defer func() { span.EndWithError(err) }()
memberships, err := repo.searchUserMemberships(ctx)
memberships, err := repo.searchUserMemberships(ctx, orgID)
if err != nil {
return nil, err
}
return userMembershipsToMemberships(memberships), nil
}
func (repo *UserMembershipRepo) searchUserMemberships(ctx context.Context) (_ []*query.Membership, err error) {
func (repo *UserMembershipRepo) searchUserMemberships(ctx context.Context, orgID string) (_ []*query.Membership, err error) {
ctx, span := tracing.NewSpan(ctx)
defer func() { span.EndWithError(err) }()
ctxData := authz.GetCtxData(ctx)
@@ -30,11 +30,11 @@ func (repo *UserMembershipRepo) searchUserMemberships(ctx context.Context) (_ []
if err != nil {
return nil, err
}
orgIDsQuery, err := query.NewMembershipResourceOwnersSearchQuery(ctxData.OrgID, authz.GetInstance(ctx).InstanceID())
orgIDsQuery, err := query.NewMembershipResourceOwnersSearchQuery(orgID, authz.GetInstance(ctx).InstanceID())
if err != nil {
return nil, err
}
grantedIDQuery, err := query.NewMembershipGrantedOrgIDSearchQuery(ctxData.OrgID)
grantedIDQuery, err := query.NewMembershipGrantedOrgIDSearchQuery(orgID)
if err != nil {
return nil, err
}

View File

@@ -7,5 +7,5 @@ import (
)
type UserMembershipRepository interface {
SearchMyMemberships(ctx context.Context) ([]*authz.Membership, error)
SearchMyMemberships(ctx context.Context, orgID string) ([]*authz.Membership, error)
}

View File

@@ -29,6 +29,9 @@ import (
type Commands struct {
httpClient *http.Client
checkPermission permissionCheck
newEmailCode func(ctx context.Context, filter preparation.FilterToQueryReducer, codeAlg crypto.EncryptionAlgorithm) (*CryptoCodeWithExpiry, error)
eventstore *eventstore.Eventstore
static static.Storage
idGenerator id.Generator
@@ -59,7 +62,8 @@ type Commands struct {
certificateLifetime time.Duration
}
func StartCommands(es *eventstore.Eventstore,
func StartCommands(
es *eventstore.Eventstore,
defaults sd.SystemDefaults,
zitadelRoles []authz.RoleMapping,
staticStore static.Storage,
@@ -76,6 +80,7 @@ func StartCommands(es *eventstore.Eventstore,
oidcEncryption,
samlEncryption crypto.EncryptionAlgorithm,
httpClient *http.Client,
membershipsResolver authz.MembershipsResolver,
) (repo *Commands, err error) {
if externalDomain == "" {
return nil, errors.ThrowInvalidArgument(nil, "COMMAND-Df21s", "no external domain specified")
@@ -102,6 +107,10 @@ func StartCommands(es *eventstore.Eventstore,
certificateAlgorithm: samlEncryption,
webauthnConfig: webAuthN,
httpClient: httpClient,
checkPermission: func(ctx context.Context, permission, orgID, resourceID string, allowSelf bool) (err error) {
return authz.CheckPermission(ctx, membershipsResolver, zitadelRoles, permission, orgID, resourceID, allowSelf)
},
newEmailCode: newEmailCode,
}
instance_repo.RegisterEventMappers(repo.eventstore)

View File

@@ -10,24 +10,33 @@ import (
"github.com/zitadel/zitadel/internal/errors"
)
func newCryptoCodeWithExpiry(ctx context.Context, filter preparation.FilterToQueryReducer, typ domain.SecretGeneratorType, alg crypto.Crypto) (value *crypto.CryptoValue, expiry time.Duration, err error) {
type CryptoCodeWithExpiry struct {
Crypted *crypto.CryptoValue
Plain string
Expiry time.Duration
}
func newCryptoCodeWithExpiry(ctx context.Context, filter preparation.FilterToQueryReducer, typ domain.SecretGeneratorType, alg crypto.Crypto) (*CryptoCodeWithExpiry, error) {
config, err := secretGeneratorConfig(ctx, filter, typ)
if err != nil {
return nil, -1, err
return nil, err
}
code := new(CryptoCodeWithExpiry)
switch a := alg.(type) {
case crypto.HashAlgorithm:
value, _, err = crypto.NewCode(crypto.NewHashGenerator(*config, a))
code.Crypted, code.Plain, err = crypto.NewCode(crypto.NewHashGenerator(*config, a))
case crypto.EncryptionAlgorithm:
value, _, err = crypto.NewCode(crypto.NewEncryptionGenerator(*config, a))
code.Crypted, code.Plain, err = crypto.NewCode(crypto.NewEncryptionGenerator(*config, a))
default:
return nil, -1, errors.ThrowInternal(nil, "COMMA-RreV6", "Errors.Internal")
return nil, errors.ThrowInternal(nil, "COMMA-RreV6", "Errors.Internal")
}
if err != nil {
return nil, -1, err
return nil, err
}
return value, config.Expiry, nil
code.Expiry = config.Expiry
return code, nil
}
func newCryptoCodeWithPlain(ctx context.Context, filter preparation.FilterToQueryReducer, typ domain.SecretGeneratorType, alg crypto.Crypto) (value *crypto.CryptoValue, plain string, err error) {

View File

@@ -2,7 +2,6 @@ package command
import (
"context"
"time"
"github.com/zitadel/zitadel/internal/command/preparation"
"github.com/zitadel/zitadel/internal/crypto"
@@ -12,12 +11,18 @@ import (
type Email struct {
Address domain.EmailAddress
Verified bool
// ReturnCode is used if the Verified field is false
ReturnCode bool
// URLTemplate can be used to specify a custom link to be sent in the mail verification
URLTemplate string
}
func (e *Email) Validate() error {
return e.Address.Validate()
}
func newEmailCode(ctx context.Context, filter preparation.FilterToQueryReducer, alg crypto.EncryptionAlgorithm) (value *crypto.CryptoValue, expiry time.Duration, err error) {
func newEmailCode(ctx context.Context, filter preparation.FilterToQueryReducer, alg crypto.EncryptionAlgorithm) (*CryptoCodeWithExpiry, error) {
return newCryptoCodeWithExpiry(ctx, filter, domain.SecretGeneratorTypeVerifyEmailCode, alg)
}

View File

@@ -333,8 +333,9 @@ func (c *Commands) SetUpInstance(ctx context.Context, setup *InstanceSetup) (str
validations = append(validations, prepareAddUserMachineKey(machineKey, c.machineKeySize))
}
} else if setup.Org.Human != nil {
setup.Org.Human.ID = userID
validations = append(validations,
AddHumanCommand(userAgg, setup.Org.Human, c.userPasswordAlg, c.userEncryption),
c.AddHumanCommand(setup.Org.Human, orgID, c.userPasswordAlg, c.userEncryption, true),
)
}

View File

@@ -10,6 +10,7 @@ import (
"golang.org/x/text/language"
"github.com/zitadel/zitadel/internal/crypto"
"github.com/zitadel/zitadel/internal/errors"
"github.com/zitadel/zitadel/internal/eventstore"
"github.com/zitadel/zitadel/internal/eventstore/repository"
"github.com/zitadel/zitadel/internal/eventstore/repository/mock"
@@ -248,3 +249,15 @@ func (m *mockInstance) RequestedHost() string {
func (m *mockInstance) SecurityPolicyAllowedOrigins() []string {
return nil
}
func newMockPermissionCheckAllowed() permissionCheck {
return func(ctx context.Context, permission, orgID, resourceID string, allowSelf bool) (err error) {
return nil
}
}
func newMockPermissionCheckNotAllowed() permissionCheck {
return func(ctx context.Context, permission, orgID, resourceID string, allowSelf bool) (err error) {
return errors.ThrowPermissionDenied(nil, "AUTHZ-HKJD33", "Errors.PermissionDenied")
}
}

View File

@@ -11,7 +11,7 @@ import (
"github.com/zitadel/zitadel/internal/eventstore"
"github.com/zitadel/zitadel/internal/repository/org"
"github.com/zitadel/zitadel/internal/repository/project"
user_repo "github.com/zitadel/zitadel/internal/repository/user"
"github.com/zitadel/zitadel/internal/repository/user"
)
type OrgSetup struct {
@@ -22,22 +22,13 @@ type OrgSetup struct {
Roles []string
}
func (c *Commands) SetUpOrgWithIDs(ctx context.Context, o *OrgSetup, orgID, userID string, userIDs ...string) (string, *domain.ObjectDetails, error) {
existingOrg, err := c.getOrgWriteModelByID(ctx, orgID)
func (c *Commands) setUpOrgWithIDs(ctx context.Context, o *OrgSetup, orgID string, userIDs ...string) (userID string, token string, machineKey *MachineKey, details *domain.ObjectDetails, err error) {
userID, err = c.idGenerator.Next()
if err != nil {
return "", nil, err
return "", "", nil, nil, err
}
if existingOrg != nil {
return "", nil, errors.ThrowPreconditionFailed(nil, "COMMAND-poaj2", "Errors.Org.AlreadyExisting")
}
userID, _, _, details, err := c.setUpOrgWithIDs(ctx, o, orgID, userID, userIDs...)
return userID, details, err
}
func (c *Commands) setUpOrgWithIDs(ctx context.Context, o *OrgSetup, orgID, userID string, userIDs ...string) (string, string, *MachineKey, *domain.ObjectDetails, error) {
orgAgg := org.NewAggregate(orgID)
userAgg := user_repo.NewAggregate(userID, orgID)
userAgg := user.NewAggregate(userID, orgID)
roles := []string{domain.RoleOrgOwner}
if len(o.Roles) > 0 {
@@ -49,9 +40,9 @@ func (c *Commands) setUpOrgWithIDs(ctx context.Context, o *OrgSetup, orgID, user
}
var pat *PersonalAccessToken
var machineKey *MachineKey
if o.Human != nil {
validations = append(validations, AddHumanCommand(userAgg, o.Human, c.userPasswordAlg, c.userEncryption))
o.Human.ID = userID
validations = append(validations, c.AddHumanCommand(o.Human, orgID, c.userPasswordAlg, c.userEncryption, true))
} else if o.Machine != nil {
validations = append(validations, AddMachineCommand(userAgg, o.Machine.Machine))
if o.Machine.Pat != nil {
@@ -89,7 +80,6 @@ func (c *Commands) setUpOrgWithIDs(ctx context.Context, o *OrgSetup, orgID, user
return "", "", nil, nil, err
}
var token string
if pat != nil {
token = pat.Token
}
@@ -107,12 +97,7 @@ func (c *Commands) SetUpOrg(ctx context.Context, o *OrgSetup, userIDs ...string)
return "", nil, err
}
userID, err := c.idGenerator.Next()
if err != nil {
return "", nil, err
}
userID, _, _, details, err := c.setUpOrgWithIDs(ctx, o, orgID, userID, userIDs...)
userID, _, _, details, err := c.setUpOrgWithIDs(ctx, o, orgID, userIDs...)
return userID, details, err
}
@@ -365,9 +350,9 @@ func OrgUserIDPLinks(ctx context.Context, filter preparation.FilterToQueryReduce
ResourceOwner(orgID).
OrderAsc().
AddQuery().
AggregateTypes(user_repo.AggregateType).
AggregateTypes(user.AggregateType).
EventTypes(
user_repo.UserIDPLinkAddedType, user_repo.UserIDPLinkRemovedType, user_repo.UserIDPLinkCascadeRemovedType,
user.UserIDPLinkAddedType, user.UserIDPLinkRemovedType, user.UserIDPLinkCascadeRemovedType,
).Builder())
if err != nil {
return nil, err
@@ -375,13 +360,13 @@ func OrgUserIDPLinks(ctx context.Context, filter preparation.FilterToQueryReduce
links := make([]*domain.UserIDPLink, 0)
for _, event := range events {
switch eventTyped := event.(type) {
case *user_repo.UserIDPLinkAddedEvent:
case *user.UserIDPLinkAddedEvent:
links = append(links, &domain.UserIDPLink{
IDPConfigID: eventTyped.IDPConfigID,
ExternalUserID: eventTyped.ExternalUserID,
DisplayName: eventTyped.DisplayName,
})
case *user_repo.UserIDPLinkRemovedEvent:
case *user.UserIDPLinkRemovedEvent:
for i := range links {
if links[i].ExternalUserID == eventTyped.ExternalUserID &&
links[i].IDPConfigID == eventTyped.IDPConfigID {
@@ -392,7 +377,7 @@ func OrgUserIDPLinks(ctx context.Context, filter preparation.FilterToQueryReduce
}
}
case *user_repo.UserIDPLinkCascadeRemovedEvent:
case *user.UserIDPLinkCascadeRemovedEvent:
for i := range links {
if links[i].ExternalUserID == eventTyped.ExternalUserID &&
links[i].IDPConfigID == eventTyped.IDPConfigID {
@@ -495,14 +480,14 @@ func OrgUsers(ctx context.Context, filter preparation.FilterToQueryReducer, orgI
ResourceOwner(orgID).
OrderAsc().
AddQuery().
AggregateTypes(user_repo.AggregateType).
AggregateTypes(user.AggregateType).
EventTypes(
user_repo.HumanAddedType,
user_repo.MachineAddedEventType,
user_repo.HumanRegisteredType,
user_repo.UserDomainClaimedType,
user_repo.UserUserNameChangedType,
user_repo.UserRemovedType,
user.HumanAddedType,
user.MachineAddedEventType,
user.HumanRegisteredType,
user.UserDomainClaimedType,
user.UserUserNameChangedType,
user.UserRemovedType,
).Builder())
if err != nil {
return nil, err
@@ -511,25 +496,25 @@ func OrgUsers(ctx context.Context, filter preparation.FilterToQueryReducer, orgI
users := make([]userIDName, 0)
for _, event := range events {
switch eventTyped := event.(type) {
case *user_repo.HumanAddedEvent:
case *user.HumanAddedEvent:
users = append(users, userIDName{eventTyped.UserName, eventTyped.Aggregate().ID})
case *user_repo.MachineAddedEvent:
case *user.MachineAddedEvent:
users = append(users, userIDName{eventTyped.UserName, eventTyped.Aggregate().ID})
case *user_repo.HumanRegisteredEvent:
case *user.HumanRegisteredEvent:
users = append(users, userIDName{eventTyped.UserName, eventTyped.Aggregate().ID})
case *user_repo.DomainClaimedEvent:
case *user.DomainClaimedEvent:
for i := range users {
if users[i].id == eventTyped.Aggregate().ID {
users[i].name = eventTyped.UserName
}
}
case *user_repo.UsernameChangedEvent:
case *user.UsernameChangedEvent:
for i := range users {
if users[i].id == eventTyped.Aggregate().ID {
users[i].name = eventTyped.UserName
}
}
case *user_repo.UserRemovedEvent:
case *user.UserRemovedEvent:
for i := range users {
if users[i].id == eventTyped.Aggregate().ID {
users[i] = users[len(users)-1]

View File

@@ -0,0 +1,11 @@
package command
import (
"context"
)
type permissionCheck func(ctx context.Context, permission, orgID, resourceID string, allowSelf bool) (err error)
const (
permissionUserWrite = "user.write"
)

View File

@@ -2,7 +2,6 @@ package command
import (
"context"
"time"
"github.com/zitadel/zitadel/internal/command/preparation"
"github.com/zitadel/zitadel/internal/crypto"
@@ -14,6 +13,6 @@ type Phone struct {
Verified bool
}
func newPhoneCode(ctx context.Context, filter preparation.FilterToQueryReducer, alg crypto.EncryptionAlgorithm) (value *crypto.CryptoValue, expiry time.Duration, err error) {
func newPhoneCode(ctx context.Context, filter preparation.FilterToQueryReducer, alg crypto.EncryptionAlgorithm) (*CryptoCodeWithExpiry, error) {
return newCryptoCodeWithExpiry(ctx, filter, domain.SecretGeneratorTypeVerifyPhoneCode, alg)
}

View File

@@ -439,7 +439,7 @@ func ExistsUser(ctx context.Context, filter preparation.FilterToQueryReducer, id
return exists, nil
}
func newUserInitCode(ctx context.Context, filter preparation.FilterToQueryReducer, alg crypto.EncryptionAlgorithm) (value *crypto.CryptoValue, expiry time.Duration, err error) {
func newUserInitCode(ctx context.Context, filter preparation.FilterToQueryReducer, alg crypto.EncryptionAlgorithm) (*CryptoCodeWithExpiry, error) {
return newCryptoCodeWithExpiry(ctx, filter, domain.SecretGeneratorTypeInitCode, alg)
}

View File

@@ -27,6 +27,8 @@ func (c *Commands) getHuman(ctx context.Context, userID, resourceowner string) (
}
type AddHuman struct {
// ID is optional, if empty it will be generated
ID string
// Username is required
Username string
// FirstName is required
@@ -43,63 +45,98 @@ type AddHuman struct {
PreferredLanguage language.Tag
// Gender is required
Gender domain.Gender
//Phone represents an international phone number
// Phone represents an international phone number
Phone Phone
//Password is optional
// Password is optional
Password string
//BcryptedPassword is optional
// BcryptedPassword is optional
BcryptedPassword string
//PasswordChangeRequired is used if the `Password`-field is set
// PasswordChangeRequired is used if the `Password`-field is set
PasswordChangeRequired bool
Passwordless bool
ExternalIDP bool
Register bool
Metadata []*AddMetadataEntry
// Details are set after a successful execution of the command
Details *domain.ObjectDetails
// EmailCode is set by the command
EmailCode *string
}
func (c *Commands) AddHumanWithID(ctx context.Context, resourceOwner string, userID string, human *AddHuman) (*domain.HumanDetails, error) {
existingHuman, err := c.getHumanWriteModelByID(ctx, userID, resourceOwner)
if err != nil {
return nil, err
func (h *AddHuman) Validate() (err error) {
if err := h.Email.Validate(); err != nil {
return err
}
if isUserStateExists(existingHuman.UserState) {
return nil, errors.ThrowPreconditionFailed(nil, "COMMAND-k2unb", "Errors.User.AlreadyExisting")
if h.Username = strings.TrimSpace(h.Username); h.Username == "" {
return errors.ThrowInvalidArgument(nil, "V2-zzad3", "Errors.Invalid.Argument")
}
return c.addHumanWithID(ctx, resourceOwner, userID, human)
if h.FirstName = strings.TrimSpace(h.FirstName); h.FirstName == "" {
return errors.ThrowInvalidArgument(nil, "USER-UCej2", "Errors.User.Profile.FirstNameEmpty")
}
if h.LastName = strings.TrimSpace(h.LastName); h.LastName == "" {
return errors.ThrowInvalidArgument(nil, "USER-4hB7d", "Errors.User.Profile.LastNameEmpty")
}
h.ensureDisplayName()
if h.Phone.Number != "" {
if h.Phone.Number, err = h.Phone.Number.Normalize(); err != nil {
return err
}
}
for _, metadataEntry := range h.Metadata {
if err := metadataEntry.Valid(); err != nil {
return err
}
}
return nil
}
func (c *Commands) addHumanWithID(ctx context.Context, resourceOwner string, userID string, human *AddHuman) (*domain.HumanDetails, error) {
agg := user.NewAggregate(userID, resourceOwner)
cmds, err := preparation.PrepareCommands(ctx, c.eventstore.Filter, AddHumanCommand(agg, human, c.userPasswordAlg, c.userEncryption))
type AddMetadataEntry struct {
Key string
Value []byte
}
func (m *AddMetadataEntry) Valid() error {
if m.Key = strings.TrimSpace(m.Key); m.Key == "" {
return errors.ThrowInvalidArgument(nil, "USER-Drght", "Errors.User.Metadata.KeyEmpty")
}
if len(m.Value) == 0 {
return errors.ThrowInvalidArgument(nil, "USER-Dbgth", "Errors.User.Metadata.ValueEmpty")
}
return nil
}
func (c *Commands) AddHuman(ctx context.Context, resourceOwner string, human *AddHuman, allowInitMail bool) (err error) {
if resourceOwner == "" {
return errors.ThrowInvalidArgument(nil, "COMMA-5Ky74", "Errors.Internal")
}
cmds, err := preparation.PrepareCommands(ctx, c.eventstore.Filter,
c.AddHumanCommand(
human,
resourceOwner,
c.userPasswordAlg,
c.userEncryption,
allowInitMail,
))
if err != nil {
return nil, err
return err
}
events, err := c.eventstore.Push(ctx, cmds...)
if err != nil {
return nil, err
return err
}
human.Details = &domain.ObjectDetails{
Sequence: events[len(events)-1].Sequence(),
EventDate: events[len(events)-1].CreationDate(),
ResourceOwner: events[len(events)-1].Aggregate().ResourceOwner,
}
return &domain.HumanDetails{
ID: userID,
ObjectDetails: domain.ObjectDetails{
Sequence: events[len(events)-1].Sequence(),
EventDate: events[len(events)-1].CreationDate(),
ResourceOwner: events[len(events)-1].Aggregate().ResourceOwner,
},
}, nil
}
func (c *Commands) AddHuman(ctx context.Context, resourceOwner string, human *AddHuman) (*domain.HumanDetails, error) {
if resourceOwner == "" {
return nil, errors.ThrowInvalidArgument(nil, "COMMA-5Ky74", "Errors.Internal")
}
userID, err := c.idGenerator.Next()
if err != nil {
return nil, err
}
return c.addHumanWithID(ctx, resourceOwner, userID, human)
return nil
}
type humanCreationCommand interface {
@@ -108,30 +145,18 @@ type humanCreationCommand interface {
AddPasswordData(secret *crypto.CryptoValue, changeRequired bool)
}
func AddHumanCommand(a *user.Aggregate, human *AddHuman, passwordAlg crypto.HashAlgorithm, codeAlg crypto.EncryptionAlgorithm) preparation.Validation {
func (c *Commands) AddHumanCommand(human *AddHuman, orgID string, passwordAlg crypto.HashAlgorithm, codeAlg crypto.EncryptionAlgorithm, allowInitMail bool) preparation.Validation {
return func() (_ preparation.CreateCommands, err error) {
if err := human.Email.Validate(); err != nil {
if err := human.Validate(); err != nil {
return nil, err
}
if human.Username = strings.TrimSpace(human.Username); human.Username == "" {
return nil, errors.ThrowInvalidArgument(nil, "V2-zzad3", "Errors.Invalid.Argument")
}
if human.FirstName = strings.TrimSpace(human.FirstName); human.FirstName == "" {
return nil, errors.ThrowInvalidArgument(nil, "USER-UCej2", "Errors.User.Profile.FirstNameEmpty")
}
if human.LastName = strings.TrimSpace(human.LastName); human.LastName == "" {
return nil, errors.ThrowInvalidArgument(nil, "USER-4hB7d", "Errors.User.Profile.LastNameEmpty")
}
human.ensureDisplayName()
if human.Phone.Number != "" {
if human.Phone.Number, err = human.Phone.Number.Normalize(); err != nil {
return nil, err
}
}
return func(ctx context.Context, filter preparation.FilterToQueryReducer) ([]eventstore.Command, error) {
if err := c.addHumanCommandCheckID(ctx, filter, human, orgID); err != nil {
return nil, err
}
a := user.NewAggregate(human.ID, orgID)
domainPolicy, err := domainPolicyWriteModel(ctx, filter, a.ResourceOwner)
if err != nil {
return nil, err
@@ -176,55 +201,30 @@ func AddHumanCommand(a *user.Aggregate, human *AddHuman, passwordAlg crypto.Hash
createCmd.AddPhoneData(human.Phone.Number)
}
if human.Password != "" {
if err = humanValidatePassword(ctx, filter, human.Password); err != nil {
return nil, err
}
secret, err := crypto.Hash([]byte(human.Password), passwordAlg)
if err != nil {
return nil, err
}
createCmd.AddPasswordData(secret, human.PasswordChangeRequired)
}
if human.BcryptedPassword != "" {
createCmd.AddPasswordData(crypto.FillHash([]byte(human.BcryptedPassword), passwordAlg), human.PasswordChangeRequired)
if err := addHumanCommandPassword(ctx, filter, createCmd, human, passwordAlg); err != nil {
return nil, err
}
cmds := make([]eventstore.Command, 0, 3)
cmds = append(cmds, createCmd)
if human.Email.Verified {
cmds = append(cmds, user.NewHumanEmailVerifiedEvent(ctx, &a.Aggregate))
}
//add init code if
// email not verified or
// user not registered and password set
if human.shouldAddInitCode() {
value, expiry, err := newUserInitCode(ctx, filter, codeAlg)
if err != nil {
return nil, err
}
cmds = append(cmds, user.NewHumanInitialCodeAddedEvent(ctx, &a.Aggregate, value, expiry))
} else {
if !human.Email.Verified {
value, expiry, err := newEmailCode(ctx, filter, codeAlg)
if err != nil {
return nil, err
}
cmds = append(cmds, user.NewHumanEmailCodeAddedEvent(ctx, &a.Aggregate, value, expiry))
}
cmds, err = c.addHumanCommandEmail(ctx, filter, cmds, a, human, codeAlg, allowInitMail)
if err != nil {
return nil, err
}
if human.Phone.Verified {
cmds = append(cmds, user.NewHumanPhoneVerifiedEvent(ctx, &a.Aggregate))
} else if human.Phone.Number != "" {
value, expiry, err := newPhoneCode(ctx, filter, codeAlg)
if err != nil {
return nil, err
}
cmds = append(cmds, user.NewHumanPhoneCodeAddedEvent(ctx, &a.Aggregate, value, expiry))
cmds, err = c.addHumanCommandPhone(ctx, filter, cmds, a, human, codeAlg)
if err != nil {
return nil, err
}
for _, metadataEntry := range human.Metadata {
cmds = append(cmds, user.NewMetadataSetEvent(
ctx,
&a.Aggregate,
metadataEntry.Key,
metadataEntry.Value,
))
}
return cmds, nil
@@ -232,6 +232,85 @@ func AddHumanCommand(a *user.Aggregate, human *AddHuman, passwordAlg crypto.Hash
}
}
func (c *Commands) addHumanCommandEmail(ctx context.Context, filter preparation.FilterToQueryReducer, cmds []eventstore.Command, a *user.Aggregate, human *AddHuman, codeAlg crypto.EncryptionAlgorithm, allowInitMail bool) ([]eventstore.Command, error) {
if human.Email.Verified {
cmds = append(cmds, user.NewHumanEmailVerifiedEvent(ctx, &a.Aggregate))
}
// if allowInitMail, used for v1 api (system, admin, mgmt, auth):
// add init code if
// email not verified or
// user not registered and password set
if allowInitMail && human.shouldAddInitCode() {
initCode, err := newUserInitCode(ctx, filter, codeAlg)
if err != nil {
return nil, err
}
return append(cmds, user.NewHumanInitialCodeAddedEvent(ctx, &a.Aggregate, initCode.Crypted, initCode.Expiry)), nil
}
if !human.Email.Verified {
emailCode, err := c.newEmailCode(ctx, filter, codeAlg)
if err != nil {
return nil, err
}
if human.Email.ReturnCode {
human.EmailCode = &emailCode.Plain
}
return append(cmds, user.NewHumanEmailCodeAddedEventV2(ctx, &a.Aggregate, emailCode.Crypted, emailCode.Expiry, human.Email.URLTemplate, human.Email.ReturnCode)), nil
}
return cmds, nil
}
func (c *Commands) addHumanCommandPhone(ctx context.Context, filter preparation.FilterToQueryReducer, cmds []eventstore.Command, a *user.Aggregate, human *AddHuman, codeAlg crypto.EncryptionAlgorithm) ([]eventstore.Command, error) {
if human.Phone.Number == "" {
return cmds, nil
}
if human.Phone.Verified {
return append(cmds, user.NewHumanPhoneVerifiedEvent(ctx, &a.Aggregate)), nil
}
phoneCode, err := newPhoneCode(ctx, filter, codeAlg)
if err != nil {
return nil, err
}
return append(cmds, user.NewHumanPhoneCodeAddedEvent(ctx, &a.Aggregate, phoneCode.Crypted, phoneCode.Expiry)), nil
}
func (c *Commands) addHumanCommandCheckID(ctx context.Context, filter preparation.FilterToQueryReducer, human *AddHuman, orgID string) (err error) {
if human.ID == "" {
human.ID, err = c.idGenerator.Next()
if err != nil {
return err
}
}
existingHuman, err := humanWriteModelByID(ctx, filter, human.ID, orgID)
if err != nil {
return err
}
if isUserStateExists(existingHuman.UserState) {
return errors.ThrowPreconditionFailed(nil, "COMMAND-k2unb", "Errors.User.AlreadyExisting")
}
return nil
}
func addHumanCommandPassword(ctx context.Context, filter preparation.FilterToQueryReducer, createCmd humanCreationCommand, human *AddHuman, passwordAlg crypto.HashAlgorithm) (err error) {
if human.Password != "" {
if err = humanValidatePassword(ctx, filter, human.Password); err != nil {
return err
}
secret, err := crypto.Hash([]byte(human.Password), passwordAlg)
if err != nil {
return err
}
createCmd.AddPasswordData(secret, human.PasswordChangeRequired)
return nil
}
if human.BcryptedPassword != "" {
createCmd.AddPasswordData(crypto.FillHash([]byte(human.BcryptedPassword), passwordAlg), human.PasswordChangeRequired)
}
return nil
}
func userValidateDomain(ctx context.Context, a *user.Aggregate, username string, mustBeDomain bool, filter preparation.FilterToQueryReducer) error {
if mustBeDomain {
return nil
@@ -507,7 +586,7 @@ func (c *Commands) createHuman(ctx context.Context, orgID string, human *domain.
if human.Email != nil && human.EmailAddress != "" && human.IsEmailVerified {
events = append(events, user.NewHumanEmailVerifiedEvent(ctx, userAgg))
} else {
emailCode, err := domain.NewEmailCode(emailCodeGenerator)
emailCode, _, err := domain.NewEmailCode(emailCodeGenerator)
if err != nil {
return nil, nil, err
}
@@ -651,3 +730,17 @@ func (c *Commands) getHumanWriteModelByID(ctx context.Context, userID, resourceo
}
return humanWriteModel, nil
}
func humanWriteModelByID(ctx context.Context, filter preparation.FilterToQueryReducer, userID, resourceowner string) (*HumanWriteModel, error) {
humanWriteModel := NewHumanWriteModel(userID, resourceowner)
events, err := filter(ctx, humanWriteModel.Query())
if err != nil {
return nil, err
}
if len(events) == 0 {
return humanWriteModel, nil
}
humanWriteModel.AppendEvents(events...)
err = humanWriteModel.Reduce()
return humanWriteModel, err
}

View File

@@ -4,6 +4,7 @@ import (
"context"
"github.com/zitadel/logging"
"github.com/zitadel/zitadel/internal/crypto"
"github.com/zitadel/zitadel/internal/domain"
caos_errs "github.com/zitadel/zitadel/internal/errors"
@@ -41,7 +42,7 @@ func (c *Commands) ChangeHumanEmail(ctx context.Context, email *domain.Email, em
if email.IsEmailVerified {
events = append(events, user.NewHumanEmailVerifiedEvent(ctx, userAgg))
} else {
emailCode, err := domain.NewEmailCode(emailCodeGenerator)
emailCode, _, err := domain.NewEmailCode(emailCodeGenerator)
if err != nil {
return nil, err
}
@@ -113,7 +114,7 @@ func (c *Commands) CreateHumanEmailVerificationCode(ctx context.Context, userID,
return nil, caos_errs.ThrowPreconditionFailed(nil, "COMMAND-3M9ds", "Errors.User.Email.AlreadyVerified")
}
userAgg := UserAggregateFromWriteModel(&existingEmail.WriteModel)
emailCode, err := domain.NewEmailCode(emailCodeGenerator)
emailCode, _, err := domain.NewEmailCode(emailCodeGenerator)
if err != nil {
return nil, err
}

View File

@@ -4,10 +4,9 @@ import (
"context"
"time"
"github.com/zitadel/zitadel/internal/eventstore"
"github.com/zitadel/zitadel/internal/crypto"
"github.com/zitadel/zitadel/internal/domain"
"github.com/zitadel/zitadel/internal/eventstore"
"github.com/zitadel/zitadel/internal/repository/user"
)

View File

@@ -5,6 +5,7 @@ import (
"github.com/zitadel/logging"
"github.com/zitadel/zitadel/internal/api/authz"
"github.com/zitadel/zitadel/internal/crypto"
"github.com/zitadel/zitadel/internal/domain"
caos_errs "github.com/zitadel/zitadel/internal/errors"
@@ -71,11 +72,14 @@ func (c *Commands) AddHumanOTP(ctx context.Context, userID, resourceowner string
if accountName == "" {
accountName = string(human.EmailAddress)
}
key, secret, err := domain.NewOTPKey(c.multifactors.OTP.Issuer, accountName, c.multifactors.OTP.CryptoMFA)
issuer := c.multifactors.OTP.Issuer
if issuer == "" {
issuer = authz.GetInstance(ctx).RequestedDomain()
}
key, secret, err := domain.NewOTPKey(issuer, accountName, c.multifactors.OTP.CryptoMFA)
if err != nil {
return nil, err
}
_, err = c.eventstore.Push(ctx, user.NewHumanOTPAddedEvent(ctx, userAgg, secret))
if err != nil {
return nil, err

View File

@@ -2,17 +2,19 @@ package command
import (
"context"
"errors"
"testing"
"time"
"github.com/golang/mock/gomock"
"github.com/muhlemmer/gu"
"github.com/stretchr/testify/assert"
"golang.org/x/text/language"
"github.com/zitadel/zitadel/internal/command/preparation"
"github.com/zitadel/zitadel/internal/crypto"
"github.com/zitadel/zitadel/internal/domain"
"github.com/zitadel/zitadel/internal/errors"
caos_errs "github.com/zitadel/zitadel/internal/errors"
"github.com/zitadel/zitadel/internal/eventstore"
"github.com/zitadel/zitadel/internal/eventstore/repository"
"github.com/zitadel/zitadel/internal/eventstore/v1/models"
@@ -29,16 +31,20 @@ func TestCommandSide_AddHuman(t *testing.T) {
idGenerator id.Generator
userPasswordAlg crypto.HashAlgorithm
codeAlg crypto.EncryptionAlgorithm
newEmailCode func(ctx context.Context, filter preparation.FilterToQueryReducer, alg crypto.EncryptionAlgorithm) (*CryptoCodeWithExpiry, error)
}
type args struct {
ctx context.Context
orgID string
human *AddHuman
secretGenerator crypto.Generator
allowInitMail bool
}
type res struct {
want *domain.HumanDetails
err func(error) bool
want *domain.ObjectDetails
wantID string
wantEmailCode string
err func(error) bool
}
userAgg := user.NewAggregate("user1", "org1")
@@ -68,9 +74,67 @@ func TestCommandSide_AddHuman(t *testing.T) {
Address: "email@test.ch",
},
},
allowInitMail: true,
},
res: res{
err: errors.IsErrorInvalidArgument,
err: func(err error) bool {
return errors.Is(err, caos_errs.ThrowInvalidArgument(nil, "COMMA-5Ky74", "Errors.Internal"))
},
},
},
{
name: "user invalid, invalid argument error",
fields: fields{
eventstore: eventstoreExpect(
t,
),
},
args: args{
ctx: context.Background(),
orgID: "org1",
human: &AddHuman{
Username: "username",
FirstName: "firstname",
},
allowInitMail: true,
},
res: res{
err: func(err error) bool {
return errors.Is(err, caos_errs.ThrowInvalidArgument(nil, "EMAIL-spblu", "Errors.User.Email.Empty"))
},
},
},
{
name: "with id, already exists, precondition error",
fields: fields{
eventstore: eventstoreExpect(
t,
expectFilter(
eventFromEventPusher(
newAddHumanEvent("password", true, ""),
),
),
),
},
args: args{
ctx: context.Background(),
orgID: "org1",
human: &AddHuman{
ID: "user1",
Username: "username",
FirstName: "firstname",
LastName: "lastname",
Email: Email{
Address: "email@test.ch",
},
PreferredLanguage: language.English,
},
allowInitMail: true,
},
res: res{
err: func(err error) bool {
return errors.Is(err, caos_errs.ThrowPreconditionFailed(nil, "COMMAND-k2unb", "Errors.User.AlreadyExisting"))
},
},
},
{
@@ -81,6 +145,7 @@ func TestCommandSide_AddHuman(t *testing.T) {
t,
expectFilter(),
expectFilter(),
expectFilter(),
),
},
args: args{
@@ -95,9 +160,12 @@ func TestCommandSide_AddHuman(t *testing.T) {
},
PreferredLanguage: language.English,
},
allowInitMail: true,
},
res: res{
err: errors.IsInternal,
err: func(err error) bool {
return errors.Is(err, caos_errs.ThrowInternal(nil, "USER-Ggk9n", "Errors.Internal"))
},
},
},
{
@@ -106,6 +174,7 @@ func TestCommandSide_AddHuman(t *testing.T) {
idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "user1"),
eventstore: eventstoreExpect(
t,
expectFilter(),
expectFilter(
eventFromEventPusher(
org.NewDomainPolicyAddedEvent(context.Background(),
@@ -134,36 +203,20 @@ func TestCommandSide_AddHuman(t *testing.T) {
},
PreferredLanguage: language.English,
},
allowInitMail: true,
},
res: res{
err: errors.IsInternal,
},
},
{
name: "user invalid, invalid argument error",
fields: fields{
idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "user1"),
eventstore: eventstoreExpect(
t,
),
},
args: args{
ctx: context.Background(),
orgID: "org1",
human: &AddHuman{
Username: "username",
FirstName: "firstname",
err: func(err error) bool {
return errors.Is(err, caos_errs.ThrowInternal(nil, "USER-uQ96e", "Errors.Internal"))
},
},
res: res{
err: errors.IsErrorInvalidArgument,
},
},
{
name: "add human (with initial code), ok",
fields: fields{
eventstore: eventstoreExpect(
t,
expectFilter(),
expectFilter(
eventFromEventPusher(
org.NewDomainPolicyAddedEvent(context.Background(),
@@ -237,16 +290,15 @@ func TestCommandSide_AddHuman(t *testing.T) {
PreferredLanguage: language.English,
},
secretGenerator: GetMockSecretGenerator(t),
allowInitMail: true,
},
res: res{
want: &domain.HumanDetails{
ID: "user1",
ObjectDetails: domain.ObjectDetails{
Sequence: 0,
EventDate: time.Time{},
ResourceOwner: "org1",
},
want: &domain.ObjectDetails{
Sequence: 0,
EventDate: time.Time{},
ResourceOwner: "org1",
},
wantID: "user1",
},
},
{
@@ -254,6 +306,7 @@ func TestCommandSide_AddHuman(t *testing.T) {
fields: fields{
eventstore: eventstoreExpect(
t,
expectFilter(),
expectFilter(
eventFromEventPusher(
org.NewDomainPolicyAddedEvent(context.Background(),
@@ -330,14 +383,174 @@ func TestCommandSide_AddHuman(t *testing.T) {
PreferredLanguage: language.English,
},
secretGenerator: GetMockSecretGenerator(t),
allowInitMail: true,
},
res: res{
want: &domain.HumanDetails{
ID: "user1",
ObjectDetails: domain.ObjectDetails{
ResourceOwner: "org1",
},
want: &domain.ObjectDetails{
ResourceOwner: "org1",
},
wantID: "user1",
},
},
{
name: "add human (with password and email code custom template), ok",
fields: fields{
eventstore: eventstoreExpect(
t,
expectFilter(),
expectFilter(
eventFromEventPusher(
org.NewDomainPolicyAddedEvent(context.Background(),
&user.NewAggregate("user1", "org1").Aggregate,
true,
true,
true,
),
),
),
expectFilter(
eventFromEventPusher(
org.NewPasswordComplexityPolicyAddedEvent(context.Background(),
&user.NewAggregate("user1", "org1").Aggregate,
1,
false,
false,
false,
false,
),
),
),
expectPush(
[]*repository.Event{
eventFromEventPusher(
newAddHumanEvent("password", false, ""),
),
eventFromEventPusher(
user.NewHumanEmailCodeAddedEventV2(context.Background(),
&user.NewAggregate("user1", "org1").Aggregate,
&crypto.CryptoValue{
CryptoType: crypto.TypeEncryption,
Algorithm: "enc",
KeyID: "id",
Crypted: []byte("emailCode"),
},
1*time.Hour,
"https://example.com/email/verify?userID={{.UserID}}&code={{.Code}}",
false,
),
),
},
uniqueConstraintsFromEventConstraint(user.NewAddUsernameUniqueConstraint("username", "org1", true)),
),
),
idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "user1"),
userPasswordAlg: crypto.CreateMockHashAlg(gomock.NewController(t)),
codeAlg: crypto.CreateMockEncryptionAlg(gomock.NewController(t)),
newEmailCode: mockEmailCode("emailCode", time.Hour),
},
args: args{
ctx: context.Background(),
orgID: "org1",
human: &AddHuman{
Username: "username",
Password: "password",
FirstName: "firstname",
LastName: "lastname",
Email: Email{
Address: "email@test.ch",
URLTemplate: "https://example.com/email/verify?userID={{.UserID}}&code={{.Code}}",
},
PreferredLanguage: language.English,
},
secretGenerator: GetMockSecretGenerator(t),
allowInitMail: false,
},
res: res{
want: &domain.ObjectDetails{
ResourceOwner: "org1",
},
wantID: "user1",
},
},
{
name: "add human (with password and return email code), ok",
fields: fields{
eventstore: eventstoreExpect(
t,
expectFilter(),
expectFilter(
eventFromEventPusher(
org.NewDomainPolicyAddedEvent(context.Background(),
&user.NewAggregate("user1", "org1").Aggregate,
true,
true,
true,
),
),
),
expectFilter(
eventFromEventPusher(
org.NewPasswordComplexityPolicyAddedEvent(context.Background(),
&user.NewAggregate("user1", "org1").Aggregate,
1,
false,
false,
false,
false,
),
),
),
expectPush(
[]*repository.Event{
eventFromEventPusher(
newAddHumanEvent("password", false, ""),
),
eventFromEventPusher(
user.NewHumanEmailCodeAddedEventV2(context.Background(),
&user.NewAggregate("user1", "org1").Aggregate,
&crypto.CryptoValue{
CryptoType: crypto.TypeEncryption,
Algorithm: "enc",
KeyID: "id",
Crypted: []byte("emailCode"),
},
1*time.Hour,
"",
true,
),
),
},
uniqueConstraintsFromEventConstraint(user.NewAddUsernameUniqueConstraint("username", "org1", true)),
),
),
idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "user1"),
userPasswordAlg: crypto.CreateMockHashAlg(gomock.NewController(t)),
codeAlg: crypto.CreateMockEncryptionAlg(gomock.NewController(t)),
newEmailCode: mockEmailCode("emailCode", time.Hour),
},
args: args{
ctx: context.Background(),
orgID: "org1",
human: &AddHuman{
Username: "username",
Password: "password",
FirstName: "firstname",
LastName: "lastname",
Email: Email{
Address: "email@test.ch",
ReturnCode: true,
},
PreferredLanguage: language.English,
},
secretGenerator: GetMockSecretGenerator(t),
allowInitMail: false,
},
res: res{
want: &domain.ObjectDetails{
ResourceOwner: "org1",
},
wantID: "user1",
wantEmailCode: "emailCode",
},
},
{
@@ -345,6 +558,7 @@ func TestCommandSide_AddHuman(t *testing.T) {
fields: fields{
eventstore: eventstoreExpect(
t,
expectFilter(),
expectFilter(
eventFromEventPusher(
org.NewDomainPolicyAddedEvent(context.Background(),
@@ -400,14 +614,13 @@ func TestCommandSide_AddHuman(t *testing.T) {
PasswordChangeRequired: true,
},
secretGenerator: GetMockSecretGenerator(t),
allowInitMail: true,
},
res: res{
want: &domain.HumanDetails{
ID: "user1",
ObjectDetails: domain.ObjectDetails{
ResourceOwner: "org1",
},
want: &domain.ObjectDetails{
ResourceOwner: "org1",
},
wantID: "user1",
},
},
{
@@ -415,6 +628,7 @@ func TestCommandSide_AddHuman(t *testing.T) {
fields: fields{
eventstore: eventstoreExpect(
t,
expectFilter(),
expectFilter(
eventFromEventPusher(
org.NewDomainPolicyAddedEvent(context.Background(),
@@ -470,14 +684,13 @@ func TestCommandSide_AddHuman(t *testing.T) {
PasswordChangeRequired: true,
},
secretGenerator: GetMockSecretGenerator(t),
allowInitMail: true,
},
res: res{
want: &domain.HumanDetails{
ID: "user1",
ObjectDetails: domain.ObjectDetails{
ResourceOwner: "org1",
},
want: &domain.ObjectDetails{
ResourceOwner: "org1",
},
wantID: "user1",
},
},
{
@@ -485,6 +698,7 @@ func TestCommandSide_AddHuman(t *testing.T) {
fields: fields{
eventstore: eventstoreExpect(
t,
expectFilter(),
expectFilter(
eventFromEventPusher(
org.NewDomainPolicyAddedEvent(context.Background(),
@@ -540,14 +754,13 @@ func TestCommandSide_AddHuman(t *testing.T) {
PasswordChangeRequired: true,
},
secretGenerator: GetMockSecretGenerator(t),
allowInitMail: true,
},
res: res{
want: &domain.HumanDetails{
ID: "user1",
ObjectDetails: domain.ObjectDetails{
ResourceOwner: "org1",
},
want: &domain.ObjectDetails{
ResourceOwner: "org1",
},
wantID: "user1",
},
},
{
@@ -555,6 +768,7 @@ func TestCommandSide_AddHuman(t *testing.T) {
fields: fields{
eventstore: eventstoreExpect(
t,
expectFilter(),
expectFilter(
eventFromEventPusher(
org.NewDomainPolicyAddedEvent(context.Background(),
@@ -594,9 +808,12 @@ func TestCommandSide_AddHuman(t *testing.T) {
PasswordChangeRequired: true,
},
secretGenerator: GetMockSecretGenerator(t),
allowInitMail: true,
},
res: res{
err: errors.IsErrorInvalidArgument,
err: func(err error) bool {
return errors.Is(err, caos_errs.ThrowInvalidArgument(nil, "COMMAND-SFd21", "Errors.User.DomainNotAllowedAsUsername"))
},
},
},
{
@@ -604,6 +821,7 @@ func TestCommandSide_AddHuman(t *testing.T) {
fields: fields{
eventstore: eventstoreExpect(
t,
expectFilter(),
expectFilter(
eventFromEventPusher(
org.NewDomainPolicyAddedEvent(context.Background(),
@@ -687,15 +905,14 @@ func TestCommandSide_AddHuman(t *testing.T) {
PasswordChangeRequired: true,
},
secretGenerator: GetMockSecretGenerator(t),
allowInitMail: true,
},
res: res{
want: &domain.HumanDetails{
ID: "user1",
ObjectDetails: domain.ObjectDetails{
ResourceOwner: "org1",
},
want: &domain.ObjectDetails{
ResourceOwner: "org1",
},
wantID: "user1",
},
},
{
@@ -703,6 +920,7 @@ func TestCommandSide_AddHuman(t *testing.T) {
fields: fields{
eventstore: eventstoreExpect(
t,
expectFilter(),
expectFilter(
eventFromEventPusher(
org.NewDomainPolicyAddedEvent(context.Background(),
@@ -787,14 +1005,13 @@ func TestCommandSide_AddHuman(t *testing.T) {
PreferredLanguage: language.English,
},
secretGenerator: GetMockSecretGenerator(t),
allowInitMail: true,
},
res: res{
want: &domain.HumanDetails{
ID: "user1",
ObjectDetails: domain.ObjectDetails{
ResourceOwner: "org1",
},
want: &domain.ObjectDetails{
ResourceOwner: "org1",
},
wantID: "user1",
},
},
{
@@ -802,6 +1019,7 @@ func TestCommandSide_AddHuman(t *testing.T) {
fields: fields{
eventstore: eventstoreExpect(
t,
expectFilter(),
expectFilter(
eventFromEventPusher(
org.NewDomainPolicyAddedEvent(context.Background(),
@@ -875,14 +1093,105 @@ func TestCommandSide_AddHuman(t *testing.T) {
PreferredLanguage: language.English,
},
secretGenerator: GetMockSecretGenerator(t),
allowInitMail: true,
},
res: res{
want: &domain.HumanDetails{
ID: "user1",
ObjectDetails: domain.ObjectDetails{
ResourceOwner: "org1",
want: &domain.ObjectDetails{
ResourceOwner: "org1",
},
wantID: "user1",
},
},
{
name: "add human with metadata, ok",
fields: fields{
eventstore: eventstoreExpect(
t,
expectFilter(),
expectFilter(
eventFromEventPusher(
org.NewDomainPolicyAddedEvent(context.Background(),
&userAgg.Aggregate,
true,
true,
true,
),
),
),
expectFilter(
eventFromEventPusher(
instance.NewSecretGeneratorAddedEvent(
context.Background(),
&instanceAgg.Aggregate,
domain.SecretGeneratorTypeInitCode,
0,
1*time.Hour,
true,
true,
true,
true,
),
),
),
expectPush(
[]*repository.Event{
eventFromEventPusher(
newAddHumanEvent("", false, ""),
),
eventFromEventPusher(
user.NewHumanInitialCodeAddedEvent(
context.Background(),
&userAgg.Aggregate,
&crypto.CryptoValue{
CryptoType: crypto.TypeEncryption,
Algorithm: "enc",
KeyID: "id",
Crypted: []byte(""),
},
1*time.Hour,
),
),
eventFromEventPusher(
user.NewMetadataSetEvent(
context.Background(),
&userAgg.Aggregate,
"testKey",
[]byte("testValue"),
),
),
},
uniqueConstraintsFromEventConstraint(user.NewAddUsernameUniqueConstraint("username", "org1", true)),
),
),
idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "user1"),
codeAlg: crypto.CreateMockEncryptionAlg(gomock.NewController(t)),
},
args: args{
ctx: context.Background(),
orgID: "org1",
human: &AddHuman{
Username: "username",
FirstName: "firstname",
LastName: "lastname",
Email: Email{
Address: "email@test.ch",
},
PreferredLanguage: language.English,
Metadata: []*AddMetadataEntry{
{
Key: "testKey",
Value: []byte("testValue"),
},
},
},
secretGenerator: GetMockSecretGenerator(t),
allowInitMail: true,
},
res: res{
want: &domain.ObjectDetails{
ResourceOwner: "org1",
},
wantID: "user1",
},
},
}
@@ -893,8 +1202,9 @@ func TestCommandSide_AddHuman(t *testing.T) {
userPasswordAlg: tt.fields.userPasswordAlg,
userEncryption: tt.fields.codeAlg,
idGenerator: tt.fields.idGenerator,
newEmailCode: tt.fields.newEmailCode,
}
got, err := r.AddHuman(tt.args.ctx, tt.args.orgID, tt.args.human)
err := r.AddHuman(tt.args.ctx, tt.args.orgID, tt.args.human, tt.args.allowInitMail)
if tt.res.err == nil {
if !assert.NoError(t, err) {
t.FailNow()
@@ -904,7 +1214,9 @@ func TestCommandSide_AddHuman(t *testing.T) {
t.Errorf("got wrong err: %v ", err)
}
if tt.res.err == nil {
assert.Equal(t, tt.res.want, got)
assert.Equal(t, tt.res.want, tt.args.human.Details)
assert.Equal(t, tt.res.wantID, tt.args.human.ID)
assert.Equal(t, tt.res.wantEmailCode, gu.Value(tt.args.human.EmailCode))
}
})
}
@@ -958,7 +1270,7 @@ func TestCommandSide_ImportHuman(t *testing.T) {
},
},
res: res{
err: errors.IsErrorInvalidArgument,
err: caos_errs.IsErrorInvalidArgument,
},
},
{
@@ -985,7 +1297,7 @@ func TestCommandSide_ImportHuman(t *testing.T) {
},
},
res: res{
err: errors.IsPreconditionFailed,
err: caos_errs.IsPreconditionFailed,
},
},
{
@@ -1022,7 +1334,7 @@ func TestCommandSide_ImportHuman(t *testing.T) {
},
},
res: res{
err: errors.IsPreconditionFailed,
err: caos_errs.IsPreconditionFailed,
},
},
{
@@ -1065,7 +1377,7 @@ func TestCommandSide_ImportHuman(t *testing.T) {
},
},
res: res{
err: errors.IsErrorInvalidArgument,
err: caos_errs.IsErrorInvalidArgument,
},
},
{
@@ -1869,7 +2181,7 @@ func TestCommandSide_RegisterHuman(t *testing.T) {
},
},
res: res{
err: errors.IsErrorInvalidArgument,
err: caos_errs.IsErrorInvalidArgument,
},
},
{
@@ -1899,7 +2211,7 @@ func TestCommandSide_RegisterHuman(t *testing.T) {
},
},
res: res{
err: errors.IsPreconditionFailed,
err: caos_errs.IsPreconditionFailed,
},
},
{
@@ -1939,7 +2251,7 @@ func TestCommandSide_RegisterHuman(t *testing.T) {
},
},
res: res{
err: errors.IsPreconditionFailed,
err: caos_errs.IsPreconditionFailed,
},
},
{
@@ -1987,7 +2299,7 @@ func TestCommandSide_RegisterHuman(t *testing.T) {
},
},
res: res{
err: errors.IsPreconditionFailed,
err: caos_errs.IsPreconditionFailed,
},
},
{
@@ -2056,7 +2368,7 @@ func TestCommandSide_RegisterHuman(t *testing.T) {
},
},
res: res{
err: errors.IsPreconditionFailed,
err: caos_errs.IsPreconditionFailed,
},
},
{
@@ -2125,7 +2437,7 @@ func TestCommandSide_RegisterHuman(t *testing.T) {
},
},
res: res{
err: errors.IsErrorInvalidArgument,
err: caos_errs.IsErrorInvalidArgument,
},
},
{
@@ -2211,7 +2523,7 @@ func TestCommandSide_RegisterHuman(t *testing.T) {
},
},
res: res{
err: errors.IsErrorInvalidArgument,
err: caos_errs.IsErrorInvalidArgument,
},
},
{
@@ -3147,7 +3459,7 @@ func TestCommandSide_HumanMFASkip(t *testing.T) {
userID: "",
},
res: res{
err: errors.IsErrorInvalidArgument,
err: caos_errs.IsErrorInvalidArgument,
},
},
{
@@ -3164,7 +3476,7 @@ func TestCommandSide_HumanMFASkip(t *testing.T) {
userID: "user1",
},
res: res{
err: errors.IsNotFound,
err: caos_errs.IsNotFound,
},
},
{
@@ -3261,7 +3573,7 @@ func TestCommandSide_HumanSignOut(t *testing.T) {
userIDs: []string{"user1"},
},
res: res{
err: errors.IsErrorInvalidArgument,
err: caos_errs.IsErrorInvalidArgument,
},
},
{
@@ -3277,7 +3589,7 @@ func TestCommandSide_HumanSignOut(t *testing.T) {
userIDs: []string{},
},
res: res{
err: errors.IsErrorInvalidArgument,
err: caos_errs.IsErrorInvalidArgument,
},
},
{
@@ -3479,37 +3791,41 @@ func newRegisterHumanEvent(username, password string, changeRequired bool, phone
}
func TestAddHumanCommand(t *testing.T) {
type fields struct {
idGenerator id.Generator
}
type args struct {
a *user.Aggregate
human *AddHuman
passwordAlg crypto.HashAlgorithm
filter preparation.FilterToQueryReducer
codeAlg crypto.EncryptionAlgorithm
human *AddHuman
orgID string
passwordAlg crypto.HashAlgorithm
filter preparation.FilterToQueryReducer
codeAlg crypto.EncryptionAlgorithm
allowInitMail bool
}
agg := user.NewAggregate("id", "ro")
tests := []struct {
name string
args args
want Want
name string
fields fields
args args
want Want
}{
{
name: "invalid email",
args: args{
a: agg,
human: &AddHuman{
Email: Email{
Address: "invalid",
},
},
orgID: "ro",
},
want: Want{
ValidationErr: errors.ThrowInvalidArgument(nil, "EMAIL-599BI", "Errors.User.Email.Invalid"),
ValidationErr: caos_errs.ThrowInvalidArgument(nil, "EMAIL-599BI", "Errors.User.Email.Invalid"),
},
},
{
name: "invalid first name",
args: args{
a: agg,
human: &AddHuman{
Username: "username",
PreferredLanguage: language.English,
@@ -3517,30 +3833,33 @@ func TestAddHumanCommand(t *testing.T) {
Address: "support@zitadel.com",
},
},
orgID: "ro",
},
want: Want{
ValidationErr: errors.ThrowInvalidArgument(nil, "USER-UCej2", "Errors.User.Profile.FirstNameEmpty"),
ValidationErr: caos_errs.ThrowInvalidArgument(nil, "USER-UCej2", "Errors.User.Profile.FirstNameEmpty"),
},
},
{
name: "invalid last name",
args: args{
a: agg,
human: &AddHuman{
Username: "username",
PreferredLanguage: language.English,
FirstName: "hurst",
Email: Email{Address: "support@zitadel.com"},
},
orgID: "ro",
},
want: Want{
ValidationErr: errors.ThrowInvalidArgument(nil, "USER-4hB7d", "Errors.User.Profile.LastNameEmpty"),
ValidationErr: caos_errs.ThrowInvalidArgument(nil, "USER-4hB7d", "Errors.User.Profile.LastNameEmpty"),
},
},
{
name: "invalid password",
fields: fields{
idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "id"),
},
args: args{
a: agg,
human: &AddHuman{
Email: Email{Address: "support@zitadel.com"},
PreferredLanguage: language.English,
@@ -3549,23 +3868,28 @@ func TestAddHumanCommand(t *testing.T) {
Password: "short",
Username: "username",
},
orgID: "ro",
filter: NewMultiFilter().Append(
func(ctx context.Context, queryFactory *eventstore.SearchQueryBuilder) ([]eventstore.Event, error) {
return []eventstore.Event{
org.NewDomainPolicyAddedEvent(
context.Background(),
&org.NewAggregate("id").Aggregate,
true,
true,
true,
),
}, nil
return []eventstore.Event{}, nil
}).
Append(
func(ctx context.Context, queryFactory *eventstore.SearchQueryBuilder) ([]eventstore.Event, error) {
return []eventstore.Event{
org.NewDomainPolicyAddedEvent(
ctx,
&org.NewAggregate("id").Aggregate,
true,
true,
true,
),
}, nil
}).
Append(
func(ctx context.Context, queryFactory *eventstore.SearchQueryBuilder) ([]eventstore.Event, error) {
return []eventstore.Event{
org.NewPasswordComplexityPolicyAddedEvent(
context.Background(),
ctx,
&org.NewAggregate("id").Aggregate,
8,
true,
@@ -3578,13 +3902,15 @@ func TestAddHumanCommand(t *testing.T) {
Filter(),
},
want: Want{
CreateErr: errors.ThrowInvalidArgument(nil, "COMMA-HuJf6", "Errors.User.PasswordComplexityPolicy.MinLength"),
CreateErr: caos_errs.ThrowInvalidArgument(nil, "COMMA-HuJf6", "Errors.User.PasswordComplexityPolicy.MinLength"),
},
},
{
name: "correct",
fields: fields{
idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "id"),
},
args: args{
a: agg,
human: &AddHuman{
Email: Email{Address: "support@zitadel.com", Verified: true},
PreferredLanguage: language.English,
@@ -3593,25 +3919,30 @@ func TestAddHumanCommand(t *testing.T) {
Password: "password",
Username: "username",
},
orgID: "ro",
passwordAlg: crypto.CreateMockHashAlg(gomock.NewController(t)),
codeAlg: crypto.CreateMockEncryptionAlg(gomock.NewController(t)),
filter: NewMultiFilter().Append(
func(ctx context.Context, queryFactory *eventstore.SearchQueryBuilder) ([]eventstore.Event, error) {
return []eventstore.Event{
org.NewDomainPolicyAddedEvent(
context.Background(),
&org.NewAggregate("id").Aggregate,
true,
true,
true,
),
}, nil
return []eventstore.Event{}, nil
}).
Append(
func(ctx context.Context, queryFactory *eventstore.SearchQueryBuilder) ([]eventstore.Event, error) {
return []eventstore.Event{
org.NewDomainPolicyAddedEvent(
ctx,
&org.NewAggregate("id").Aggregate,
true,
true,
true,
),
}, nil
}).
Append(
func(ctx context.Context, queryFactory *eventstore.SearchQueryBuilder) ([]eventstore.Event, error) {
return []eventstore.Event{
org.NewPasswordComplexityPolicyAddedEvent(
context.Background(),
ctx,
&org.NewAggregate("id").Aggregate,
2,
false,
@@ -3654,7 +3985,25 @@ func TestAddHumanCommand(t *testing.T) {
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
AssertValidation(t, context.Background(), AddHumanCommand(tt.args.a, tt.args.human, tt.args.passwordAlg, tt.args.codeAlg), tt.args.filter, tt.want)
c := &Commands{
idGenerator: tt.fields.idGenerator,
}
AssertValidation(t, context.Background(), c.AddHumanCommand(tt.args.human, tt.args.orgID, tt.args.passwordAlg, tt.args.codeAlg, tt.args.allowInitMail), tt.args.filter, tt.want)
})
}
}
func mockEmailCode(code string, exp time.Duration) func(ctx context.Context, filter preparation.FilterToQueryReducer, alg crypto.EncryptionAlgorithm) (*CryptoCodeWithExpiry, error) {
return func(ctx context.Context, filter preparation.FilterToQueryReducer, alg crypto.EncryptionAlgorithm) (*CryptoCodeWithExpiry, error) {
return &CryptoCodeWithExpiry{
Crypted: &crypto.CryptoValue{
CryptoType: crypto.TypeEncryption,
Algorithm: "enc",
KeyID: "id",
Crypted: []byte(code),
},
Plain: code,
Expiry: exp,
}, nil
}
}

View File

@@ -0,0 +1,208 @@
package command
import (
"context"
"io"
"github.com/zitadel/logging"
"github.com/zitadel/zitadel/internal/crypto"
"github.com/zitadel/zitadel/internal/domain"
caos_errs "github.com/zitadel/zitadel/internal/errors"
"github.com/zitadel/zitadel/internal/eventstore"
"github.com/zitadel/zitadel/internal/repository/user"
)
// ChangeUserEmail sets a user's email address, generates a code
// and triggers a notification e-mail with the default confirmation URL format.
func (c *Commands) ChangeUserEmail(ctx context.Context, userID, resourceOwner, email string, alg crypto.EncryptionAlgorithm) (*domain.Email, error) {
return c.changeUserEmailWithCode(ctx, userID, resourceOwner, email, alg, false, "")
}
// ChangeUserEmailURLTemplate sets a user's email address, generates a code
// and triggers a notification e-mail with the confirmation URL rendered from the passed urlTmpl.
// urlTmpl must be a valid [tmpl.Template].
func (c *Commands) ChangeUserEmailURLTemplate(ctx context.Context, userID, resourceOwner, email string, alg crypto.EncryptionAlgorithm, urlTmpl string) (*domain.Email, error) {
if err := domain.RenderConfirmURLTemplate(io.Discard, urlTmpl, userID, "code", "orgID"); err != nil {
return nil, err
}
return c.changeUserEmailWithCode(ctx, userID, resourceOwner, email, alg, false, urlTmpl)
}
// ChangeUserEmailReturnCode sets a user's email address, generates a code and does not send a notification email.
// The generated plain text code will be set in the returned Email object.
func (c *Commands) ChangeUserEmailReturnCode(ctx context.Context, userID, resourceOwner, email string, alg crypto.EncryptionAlgorithm) (*domain.Email, error) {
return c.changeUserEmailWithCode(ctx, userID, resourceOwner, email, alg, true, "")
}
// ChangeUserEmailVerified sets a user's email address and marks it is verified.
// No code is generated and no confirmation e-mail is send.
func (c *Commands) ChangeUserEmailVerified(ctx context.Context, userID, resourceOwner, email string) (*domain.Email, error) {
cmd, err := c.NewUserEmailEvents(ctx, userID, resourceOwner)
if err != nil {
return nil, err
}
if err = c.checkPermission(ctx, permissionUserWrite, cmd.aggregate.ResourceOwner, userID, false); err != nil {
return nil, err
}
if err = cmd.Change(ctx, domain.EmailAddress(email)); err != nil {
return nil, err
}
cmd.SetVerified(ctx)
return cmd.Push(ctx)
}
func (c *Commands) changeUserEmailWithCode(ctx context.Context, userID, resourceOwner, email string, alg crypto.EncryptionAlgorithm, returnCode bool, urlTmpl string) (*domain.Email, error) {
config, err := secretGeneratorConfig(ctx, c.eventstore.Filter, domain.SecretGeneratorTypeVerifyEmailCode)
if err != nil {
return nil, err
}
gen := crypto.NewEncryptionGenerator(*config, alg)
return c.changeUserEmailWithGenerator(ctx, userID, resourceOwner, email, gen, returnCode, urlTmpl)
}
// changeUserEmailWithGenerator set a user's email address.
// returnCode controls if the plain text version of the code will be set in the return object.
// When the plain text code is returned, no notification e-mail will be send to the user.
// urlTmpl allows changing the target URL that is used by the e-mail and should be a validated Go template, if used.
func (c *Commands) changeUserEmailWithGenerator(ctx context.Context, userID, resourceOwner, email string, gen crypto.Generator, returnCode bool, urlTmpl string) (*domain.Email, error) {
cmd, err := c.NewUserEmailEvents(ctx, userID, resourceOwner)
if err != nil {
return nil, err
}
if err = c.checkPermission(ctx, permissionUserWrite, cmd.aggregate.ResourceOwner, userID, true); err != nil {
return nil, err
}
if err = cmd.Change(ctx, domain.EmailAddress(email)); err != nil {
return nil, err
}
if err = cmd.AddGeneratedCode(ctx, gen, urlTmpl, returnCode); err != nil {
return nil, err
}
return cmd.Push(ctx)
}
func (c *Commands) VerifyUserEmail(ctx context.Context, userID, resourceOwner, code string, alg crypto.EncryptionAlgorithm) (*domain.ObjectDetails, error) {
config, err := secretGeneratorConfig(ctx, c.eventstore.Filter, domain.SecretGeneratorTypeVerifyEmailCode)
if err != nil {
return nil, err
}
gen := crypto.NewEncryptionGenerator(*config, alg)
return c.verifyUserEmailWithGenerator(ctx, userID, resourceOwner, code, gen)
}
func (c *Commands) verifyUserEmailWithGenerator(ctx context.Context, userID, resourceOwner, code string, gen crypto.Generator) (*domain.ObjectDetails, error) {
cmd, err := c.NewUserEmailEvents(ctx, userID, resourceOwner)
if err != nil {
return nil, err
}
err = cmd.VerifyCode(ctx, code, gen)
if err != nil {
return nil, err
}
if _, err = cmd.Push(ctx); err != nil {
return nil, err
}
return writeModelToObjectDetails(&cmd.model.WriteModel), nil
}
// UserEmailEvents allows step-by-step additions of events,
// operating on the Human Email Model.
type UserEmailEvents struct {
eventstore *eventstore.Eventstore
aggregate *eventstore.Aggregate
events []eventstore.Command
model *HumanEmailWriteModel
plainCode *string
}
// NewUserEmailEvents constructs a UserEmailEvents with a Human Email Write Model,
// filtered by userID and resourceOwner.
// If a model cannot be found, or it's state is invalid and error is returned.
func (c *Commands) NewUserEmailEvents(ctx context.Context, userID, resourceOwner string) (*UserEmailEvents, error) {
if userID == "" {
return nil, caos_errs.ThrowInvalidArgument(nil, "COMMAND-0Gzs3", "Errors.User.Email.IDMissing")
}
model, err := c.emailWriteModel(ctx, userID, resourceOwner)
if err != nil {
return nil, err
}
if model.UserState == domain.UserStateUnspecified || model.UserState == domain.UserStateDeleted {
return nil, caos_errs.ThrowNotFound(nil, "COMMAND-ieJ2e", "Errors.User.Email.NotFound")
}
if model.UserState == domain.UserStateInitial {
return nil, caos_errs.ThrowPreconditionFailed(nil, "COMMAND-uz0Uu", "Errors.User.NotInitialised")
}
return &UserEmailEvents{
eventstore: c.eventstore,
aggregate: UserAggregateFromWriteModel(&model.WriteModel),
model: model,
}, nil
}
// Change sets a new email address.
// The generated event unsets any previously generated code and verified flag.
func (c *UserEmailEvents) Change(ctx context.Context, email domain.EmailAddress) error {
if err := email.Validate(); err != nil {
return err
}
event, hasChanged := c.model.NewChangedEvent(ctx, c.aggregate, email)
if !hasChanged {
return caos_errs.ThrowPreconditionFailed(nil, "COMMAND-Uch5e", "Errors.User.Email.NotChanged")
}
c.events = append(c.events, event)
return nil
}
// SetVerified sets the email address to verified.
func (c *UserEmailEvents) SetVerified(ctx context.Context) {
c.events = append(c.events, user.NewHumanEmailVerifiedEvent(ctx, c.aggregate))
}
// AddGeneratedCode generates a new encrypted code and sets it to the email address.
// When returnCode a plain text of the code will be returned from Push.
func (c *UserEmailEvents) AddGeneratedCode(ctx context.Context, gen crypto.Generator, urlTmpl string, returnCode bool) error {
value, plain, err := crypto.NewCode(gen)
if err != nil {
return err
}
c.events = append(c.events, user.NewHumanEmailCodeAddedEventV2(ctx, c.aggregate, value, gen.Expiry(), urlTmpl, returnCode))
if returnCode {
c.plainCode = &plain
}
return nil
}
func (c *UserEmailEvents) VerifyCode(ctx context.Context, code string, gen crypto.Generator) error {
if code == "" {
return caos_errs.ThrowInvalidArgument(nil, "COMMAND-Fia4a", "Errors.User.Code.Empty")
}
err := crypto.VerifyCode(c.model.CodeCreationDate, c.model.CodeExpiry, c.model.Code, code, gen)
if err == nil {
c.events = append(c.events, user.NewHumanEmailVerifiedEvent(ctx, c.aggregate))
return nil
}
_, err = c.eventstore.Push(ctx, user.NewHumanEmailVerificationFailedEvent(ctx, c.aggregate))
logging.WithFields("id", "COMMAND-Zoo6b", "userID", c.aggregate.ID).OnError(err).Error("NewHumanEmailVerificationFailedEvent push failed")
return caos_errs.ThrowInvalidArgument(err, "COMMAND-eis9R", "Errors.User.Code.Invalid")
}
// Push all events to the eventstore and Reduce them into the Model.
func (c *UserEmailEvents) Push(ctx context.Context) (*domain.Email, error) {
pushedEvents, err := c.eventstore.Push(ctx, c.events...)
if err != nil {
return nil, err
}
err = AppendAndReduce(c.model, pushedEvents...)
if err != nil {
return nil, err
}
email := writeModelToEmail(c.model)
email.PlainCode = c.plainCode
return email, nil
}

File diff suppressed because it is too large Load Diff

View File

@@ -10,11 +10,6 @@ import (
es_models "github.com/zitadel/zitadel/internal/eventstore/v1/models"
)
type HumanDetails struct {
ID string
ObjectDetails
}
type Human struct {
es_models.ObjectRoot

View File

@@ -1,12 +1,15 @@
package domain
import (
"io"
"regexp"
"strings"
"text/template"
"time"
"github.com/zitadel/zitadel/internal/crypto"
"github.com/zitadel/zitadel/internal/errors"
caos_errs "github.com/zitadel/zitadel/internal/errors"
es_models "github.com/zitadel/zitadel/internal/eventstore/v1/models"
)
@@ -35,6 +38,8 @@ type Email struct {
EmailAddress EmailAddress
IsEmailVerified bool
// PlainCode is set by the command and can be used to return it to the caller (API)
PlainCode *string
}
type EmailCode struct {
@@ -51,13 +56,36 @@ func (e *Email) Validate() error {
return e.EmailAddress.Validate()
}
func NewEmailCode(emailGenerator crypto.Generator) (*EmailCode, error) {
emailCodeCrypto, _, err := crypto.NewCode(emailGenerator)
func NewEmailCode(emailGenerator crypto.Generator) (*EmailCode, string, error) {
emailCodeCrypto, code, err := crypto.NewCode(emailGenerator)
if err != nil {
return nil, err
return nil, "", err
}
return &EmailCode{
Code: emailCodeCrypto,
Expiry: emailGenerator.Expiry(),
}, nil
}, code, nil
}
type ConfirmURLData struct {
UserID string
Code string
OrgID string
}
// RenderConfirmURLTemplate parses and renders tmplStr.
// userID, code and orgID are passed into the [ConfirmURLData].
// "%s%s?userID=%s&code=%s&orgID=%s"
func RenderConfirmURLTemplate(w io.Writer, tmplStr, userID, code, orgID string) error {
tmpl, err := template.New("").Parse(tmplStr)
if err != nil {
return caos_errs.ThrowInvalidArgument(err, "USERv2-ooD8p", "Errors.User.Email.InvalidURLTemplate")
}
data := &ConfirmURLData{userID, code, orgID}
if err = tmpl.Execute(w, data); err != nil {
return caos_errs.ThrowInvalidArgument(err, "USERv2-ohSi5", "Errors.User.Email.InvalidURLTemplate")
}
return nil
}

View File

@@ -1,7 +1,13 @@
package domain
import (
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
caos_errs "github.com/zitadel/zitadel/internal/errors"
)
func TestEmailValid(t *testing.T) {
@@ -72,3 +78,57 @@ func TestEmailValid(t *testing.T) {
})
}
}
func TestRenderConfirmURLTemplate(t *testing.T) {
type args struct {
tmplStr string
userID string
code string
orgID string
}
tests := []struct {
name string
args args
want string
wantErr error
}{
{
name: "invalid template",
args: args{
tmplStr: "{{",
userID: "user1",
code: "123",
orgID: "org1",
},
wantErr: caos_errs.ThrowInvalidArgument(nil, "USERv2-ooD8p", "Errors.User.Email.InvalidURLTemplate"),
},
{
name: "execution error",
args: args{
tmplStr: "{{.Foo}}",
userID: "user1",
code: "123",
orgID: "org1",
},
wantErr: caos_errs.ThrowInvalidArgument(nil, "USERv2-ohSi5", "Errors.User.Email.InvalidURLTemplate"),
},
{
name: "success",
args: args{
tmplStr: "https://example.com/email/verify?userID={{.UserID}}&code={{.Code}}&orgID={{.OrgID}}",
userID: "user1",
code: "123",
orgID: "org1",
},
want: "https://example.com/email/verify?userID=user1&code=123&orgID=org1",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var w strings.Builder
err := RenderConfirmURLTemplate(&w, tt.args.tmplStr, tt.args.userID, tt.args.code, tt.args.orgID)
require.ErrorIs(t, err, tt.wantErr)
assert.Equal(t, tt.want, w.String())
})
}
}

View File

@@ -223,7 +223,7 @@ func (h *ProjectionHandler) schedule(ctx context.Context) {
errs := h.lock(lockCtx, h.requeueAfter, "system")
if err, ok := <-errs; err != nil || !ok {
cancelLock()
logging.WithFields("projection", h.ProjectionName).OnError(err).Warn("initial lock failed for first schedule")
logging.WithFields("projection", h.ProjectionName).OnError(err).Debug("initial lock failed for first schedule")
h.triggerProjection.Reset(h.requeueAfter)
continue
}
@@ -253,7 +253,7 @@ func (h *ProjectionHandler) schedule(ctx context.Context) {
//wait until projection is locked
if err, ok := <-errs; err != nil || !ok {
cancelInstanceLock()
logging.WithFields("projection", h.ProjectionName).OnError(err).Warn("initial lock failed")
logging.WithFields("projection", h.ProjectionName).OnError(err).Debug("initial lock failed")
failed = true
continue
}

View File

@@ -126,7 +126,7 @@ func (s *spooledHandler) load(workerID string) {
var err error
s.succeededOnce, err = s.hasSucceededOnce(ctx)
if err != nil {
logging.WithFields("view", s.ViewModel()).OnError(err).Warn("initial lock failed for first schedule")
logging.WithFields("view", s.ViewModel()).OnError(err).Debug("initial lock failed for first schedule")
errs <- err
return
}

View File

@@ -182,6 +182,9 @@ func (u *userNotifier) reduceEmailCodeAdded(event eventstore.Event) (*handler.St
if !ok {
return nil, errors.ThrowInvalidArgumentf(nil, "HANDL-SWf3g", "reduce.wrong.event.type %s", user.HumanEmailCodeAddedType)
}
if e.CodeReturned {
return crdb.NewNoOpStatement(e), nil
}
ctx := HandlerContext(event.Aggregate())
alreadyHandled, err := u.checkIfCodeAlreadyHandledOrExpired(ctx, event, e.Expiry, nil,
user.UserV1EmailCodeAddedType, user.UserV1EmailCodeSentType,
@@ -232,7 +235,7 @@ func (u *userNotifier) reduceEmailCodeAdded(event eventstore.Event) (*handler.St
e,
u.metricSuccessfulDeliveriesEmail,
u.metricFailedDeliveriesEmail,
).SendEmailVerificationCode(notifyUser, origin, code)
).SendEmailVerificationCode(notifyUser, origin, code, e.URLTemplate)
if err != nil {
return nil, err
}

View File

@@ -1,13 +1,25 @@
package types
import (
"strings"
"github.com/zitadel/zitadel/internal/api/ui/login"
"github.com/zitadel/zitadel/internal/domain"
"github.com/zitadel/zitadel/internal/query"
)
func (notify Notify) SendEmailVerificationCode(user *query.NotifyUser, origin, code string) error {
url := login.MailVerificationLink(origin, user.ID, code, user.ResourceOwner)
func (notify Notify) SendEmailVerificationCode(user *query.NotifyUser, origin, code string, urlTmpl string) error {
var url string
if urlTmpl == "" {
url = login.MailVerificationLink(origin, user.ID, code, user.ResourceOwner)
} else {
var buf strings.Builder
if err := domain.RenderConfirmURLTemplate(&buf, urlTmpl, user.ID, code, user.ResourceOwner); err != nil {
return err
}
url = buf.String()
}
args := make(map[string]interface{})
args["Code"] = code
return notify(url, args, domain.VerifyEmailMessageType, true)

View File

@@ -0,0 +1,107 @@
package types
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/zitadel/zitadel/internal/domain"
caos_errs "github.com/zitadel/zitadel/internal/errors"
"github.com/zitadel/zitadel/internal/query"
)
func TestNotify_SendEmailVerificationCode(t *testing.T) {
type res struct {
url string
args map[string]interface{}
messageType string
allowUnverifiedNotificationChannel bool
}
notify := func(dst *res) Notify {
return func(
url string,
args map[string]interface{},
messageType string,
allowUnverifiedNotificationChannel bool,
) error {
dst.url = url
dst.args = args
dst.messageType = messageType
dst.allowUnverifiedNotificationChannel = allowUnverifiedNotificationChannel
return nil
}
}
type args struct {
user *query.NotifyUser
origin string
code string
urlTmpl string
}
tests := []struct {
name string
args args
want *res
wantErr error
}{
{
name: "default URL",
args: args{
user: &query.NotifyUser{
ID: "user1",
ResourceOwner: "org1",
},
origin: "https://example.com",
code: "123",
urlTmpl: "",
},
want: &res{
url: "https://example.com/ui/login/mail/verification?userID=user1&code=123&orgID=org1",
args: map[string]interface{}{"Code": "123"},
messageType: domain.VerifyEmailMessageType,
allowUnverifiedNotificationChannel: true,
},
},
{
name: "template error",
args: args{
user: &query.NotifyUser{
ID: "user1",
ResourceOwner: "org1",
},
origin: "https://example.com",
code: "123",
urlTmpl: "{{",
},
want: &res{},
wantErr: caos_errs.ThrowInvalidArgument(nil, "USERv2-ooD8p", "Errors.User.Email.InvalidURLTemplate"),
},
{
name: "template success",
args: args{
user: &query.NotifyUser{
ID: "user1",
ResourceOwner: "org1",
},
origin: "https://example.com",
code: "123",
urlTmpl: "https://example.com/email/verify?userID={{.UserID}}&code={{.Code}}&orgID={{.OrgID}}",
},
want: &res{
url: "https://example.com/email/verify?userID=user1&code=123&orgID=org1",
args: map[string]interface{}{"Code": "123"},
messageType: domain.VerifyEmailMessageType,
allowUnverifiedNotificationChannel: true,
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := new(res)
err := notify(got).SendEmailVerificationCode(tt.args.user, tt.args.origin, tt.args.code, tt.args.urlTmpl)
require.ErrorIs(t, err, tt.wantErr)
assert.Equal(t, tt.want, got)
})
}
}

View File

@@ -0,0 +1,16 @@
// Code generated by protoc-gen-auth. DO NOT EDIT.
package {{.GoPackageName}}
import (
"github.com/zitadel/zitadel/internal/api/authz"
)
var {{.ServiceName}}_AuthMethods = authz.MethodMapping {
{{ range $m := .AuthOptions}}
{{$.ServiceName}}_{{$m.Name}}_FullMethodName: authz.Option{
Permission: "{{$m.Permission}}",
CheckParam: "{{$m.CheckFieldName}}",
},
{{ end}}
}

View File

@@ -0,0 +1,97 @@
package main
import (
"bytes"
_ "embed"
"fmt"
"io"
"os"
"text/template"
"google.golang.org/protobuf/compiler/protogen"
"google.golang.org/protobuf/proto"
"google.golang.org/protobuf/types/descriptorpb"
"google.golang.org/protobuf/types/pluginpb"
"github.com/zitadel/zitadel/internal/protoc/protoc-gen-authoption/authoption"
)
var (
//go:embed auth_method_mapping.go.tmpl
authTemplate []byte
)
type authMethods struct {
GoPackageName string
ProtoPackageName string
ServiceName string
AuthOptions []authOption
}
type authOption struct {
Name string
Permission string
CheckFieldName string
}
func main() {
input, _ := io.ReadAll(os.Stdin)
var req pluginpb.CodeGeneratorRequest
err := proto.Unmarshal(input, &req)
if err != nil {
panic(err)
}
opts := protogen.Options{}
plugin, err := opts.New(&req)
if err != nil {
panic(err)
}
plugin.SupportedFeatures = uint64(pluginpb.CodeGeneratorResponse_FEATURE_PROTO3_OPTIONAL)
authTemp := loadTemplate(authTemplate)
for _, file := range plugin.Files {
var buf bytes.Buffer
var methods authMethods
for _, service := range file.Services {
methods.ServiceName = service.GoName
methods.GoPackageName = string(file.GoPackageName)
methods.ProtoPackageName = *file.Proto.Package
for _, method := range service.Methods {
if options := method.Desc.Options().(*descriptorpb.MethodOptions); options != nil {
ext := proto.GetExtension(options, authoption.E_AuthOption).(*authoption.AuthOption)
if ext != nil {
methods.AuthOptions = append(methods.AuthOptions, authOption{Name: string(method.Desc.Name()), Permission: ext.Permission, CheckFieldName: ext.CheckFieldName})
}
}
}
}
if len(methods.AuthOptions) > 0 {
authTemp.Execute(&buf, &methods)
filename := file.GeneratedFilenamePrefix + ".pb.authoptions.go"
file := plugin.NewGeneratedFile(filename, ".")
file.Write(buf.Bytes())
}
}
// Generate a response from our plugin and marshall as protobuf
stdout := plugin.Response()
out, err := proto.Marshal(stdout)
if err != nil {
panic(err)
}
// Write the response to stdout, to be picked up by protoc
fmt.Fprintf(os.Stdout, string(out))
}
func loadTemplate(templateData []byte) *template.Template {
return template.Must(template.New("").
Parse(string(templateData)))
}

View File

@@ -1,37 +0,0 @@
# protoc-gen-authoption
Proto options to annotate auth methods in protos
## Generate protos/templates
protos: `go generate authoption/generate.go`
templates/install: `go generate generate.go`
## Usage
```
// proto file
import "authoption/options.proto";
service MyService {
rpc Hello(Hello) returns (google.protobuf.Empty) {
option (google.api.http) = {
get: "/hello"
};
option (caos.zitadel.utils.v1.auth_option) = {
zitadel_permission: "hello.read"
zitadel_check_param: "id"
};
}
message Hello {
string id = 1;
}
}
```
Caos Auth Option is used for granting groups
On each zitadel role is specified which auth methods are allowed to call
Get protoc-get-authoption: ``go get github.com/zitadel/zitadel/internal/protoc/protoc-gen-authoption``
Protc-Flag: ``--authoption_out=.``

View File

@@ -1,4 +0,0 @@
package main
//go:generate go-bindata -pkg main -o templates.gen.go templates
//go:generate go install

View File

@@ -1,15 +0,0 @@
package main
import (
base "github.com/zitadel/zitadel/internal/protoc/protoc-base"
"github.com/zitadel/zitadel/internal/protoc/protoc-gen-authoption/authoption"
)
const (
fileName = "%v.pb.authoptions.go"
)
func main() {
base.RegisterExtension(authoption.E_AuthOption)
base.RunWithBaseTemplate(fileName, base.LoadTemplate(templatesAuth_method_mappingGoTmplBytes()))
}

View File

@@ -1,33 +0,0 @@
// Code generated by protoc-gen-authmethod. DO NOT EDIT.
package {{.File.GoPkg.Name}}
import (
"google.golang.org/grpc"
"github.com/zitadel/zitadel/internal/api/authz"
"github.com/zitadel/zitadel/internal/api/grpc/server/middleware"
)
{{ range $s := .File.Services }}
/**
* {{$s.Name}}
*/
const {{$s.Name}}_MethodPrefix = "{{$.File.Package}}.{{$s.Name}}"
var {{$s.Name}}_AuthMethods = authz.MethodMapping {
{{ range $m := $s.Method}}
{{ $mAuthOpt := option $m.Options "zitadel.v1.auth_option" }}
{{ if and $mAuthOpt $mAuthOpt.Permission }}
"/{{$.File.Package}}.{{$s.Name}}/{{.Name}}": authz.Option{
Permission: "{{$mAuthOpt.Permission}}",
CheckParam: "{{$mAuthOpt.CheckFieldName}}",
},
{{end}}
{{ end}}
}
{{ end }}

View File

@@ -19,6 +19,7 @@ const (
HumanEmailVerificationFailedType = emailEventPrefix + "verification.failed"
HumanEmailCodeAddedType = emailEventPrefix + "code.added"
HumanEmailCodeSentType = emailEventPrefix + "code.sent"
HumanEmailConfirmURLAddedType = emailEventPrefix + "confirm_url.added"
)
type HumanEmailChangedEvent struct {
@@ -121,8 +122,10 @@ func HumanEmailVerificationFailedEventMapper(event *repository.Event) (eventstor
type HumanEmailCodeAddedEvent struct {
eventstore.BaseEvent `json:"-"`
Code *crypto.CryptoValue `json:"code,omitempty"`
Expiry time.Duration `json:"expiry,omitempty"`
Code *crypto.CryptoValue `json:"code,omitempty"`
Expiry time.Duration `json:"expiry,omitempty"`
URLTemplate string `json:"url_template,omitempty"`
CodeReturned bool `json:"code_returned,omitempty"`
}
func (e *HumanEmailCodeAddedEvent) Data() interface{} {
@@ -137,15 +140,29 @@ func NewHumanEmailCodeAddedEvent(
ctx context.Context,
aggregate *eventstore.Aggregate,
code *crypto.CryptoValue,
expiry time.Duration) *HumanEmailCodeAddedEvent {
expiry time.Duration,
) *HumanEmailCodeAddedEvent {
return NewHumanEmailCodeAddedEventV2(ctx, aggregate, code, expiry, "", false)
}
func NewHumanEmailCodeAddedEventV2(
ctx context.Context,
aggregate *eventstore.Aggregate,
code *crypto.CryptoValue,
expiry time.Duration,
urlTemplate string,
codeReturned bool,
) *HumanEmailCodeAddedEvent {
return &HumanEmailCodeAddedEvent{
BaseEvent: *eventstore.NewBaseEventForPush(
ctx,
aggregate,
HumanEmailCodeAddedType,
),
Code: code,
Expiry: expiry,
Code: code,
Expiry: expiry,
URLTemplate: urlTemplate,
CodeReturned: codeReturned,
}
}

View File

@@ -81,6 +81,7 @@ Errors:
NotChanged: Email wurde nicht geändert
Empty: Email ist leer
IDMissing: Email ID fehlt
InvalidURLTemplate: URL Template ist ungültig
Phone:
NotFound: Telefonnummer nicht gefunden
Invalid: Telefonnummer ist ungültig

View File

@@ -81,6 +81,7 @@ Errors:
NotChanged: Email not changed
Empty: Email is empty
IDMissing: Email ID is missing
InvalidURLTemplate: URL Template is invalid
Phone:
NotFound: Phone not found
Invalid: Phone is invalid

View File

@@ -81,6 +81,7 @@ Errors:
NotChanged: El email no ha cambiado
Empty: El email no está vacío
IDMissing: Falta el ID del email
InvalidURLTemplate: La plantilla URL no es válida
Phone:
NotFound: Teléfono no encontrado
Invalid: El teléfono no es válido

View File

@@ -81,6 +81,7 @@ Errors:
NotChanged: L'adresse électronique n'a pas changé
Empty: Email est vide
IDMissing: Email ID manquant
InvalidURLTemplate: Le modèle d'URL n'est pas valide
Phone:
Notfound: Téléphone non trouvé
Invalid: Le téléphone n'est pas valide

View File

@@ -81,6 +81,7 @@ Errors:
NotChanged: Email non cambiata
Empty: Email è vuota
IDMissing: Email ID mancante
InvalidURLTemplate: Il modello di URL non è valido
Phone:
NotFound: Telefono non trovato
Invalid: Il telefono non è valido

View File

@@ -76,6 +76,7 @@ Errors:
Invalid: 無効なメールアドレスです
AlreadyVerified: メールアドレスはすでに検証済みです
NotChanged: メールアドレスが変更されていません
InvalidURLTemplate: URLテンプレートが無効です
Phone:
NotFound: 電話番号が見つかりません
Invalid: 無効な電話番号です

View File

@@ -81,6 +81,7 @@ Errors:
NotChanged: Adres e-mail nie zmieniony
Empty: Adres e-mail jest pusty
IDMissing: Adres e-mail ID brakuje
InvalidURLTemplate: Szablon URL jest nieprawidłowy
Phone:
NotFound: Numer telefonu nie znaleziony
Invalid: Numer telefonu jest nieprawidłowy

View File

@@ -81,6 +81,7 @@ Errors:
NotChanged: 电子邮件未更改
Empty: 电子邮件是空的
IDMissing: 电子邮件ID丢失
InvalidURLTemplate: URL模板无效
Phone:
NotFound: 手机号码未找到
Invalid: 手机号码无效

View File

@@ -18,7 +18,7 @@ import (
)
type Metrics struct {
Exporter *prometheus.Exporter
Provider metric.MeterProvider
Meter metric.Meter
Counters sync.Map
UpDownSumObserver sync.Map
@@ -34,12 +34,13 @@ func NewMetrics(meterName string) (metrics.Metrics, error) {
if err != nil {
return &Metrics{}, err
}
meterProvider := sdk_metric.NewMeterProvider(
sdk_metric.WithReader(exporter),
sdk_metric.WithResource(resource),
)
return &Metrics{
Exporter: exporter,
Meter: sdk_metric.NewMeterProvider(
sdk_metric.WithReader(exporter),
sdk_metric.WithResource(resource),
).Meter(meterName),
Provider: meterProvider,
Meter: meterProvider.Meter(meterName),
}, nil
}
@@ -48,7 +49,7 @@ func (m *Metrics) GetExporter() http.Handler {
}
func (m *Metrics) GetMetricsProvider() metric.MeterProvider {
return sdk_metric.NewMeterProvider(sdk_metric.WithReader(m.Exporter))
return m.Provider
}
func (m *Metrics) RegisterCounter(name, description string) error {