Refactoring of authentification
Signed-off-by: Bryan Frimin <bryan@getprobo.com>
This commit is contained in:
committed by
Sacha Al Himdani
parent
afa7e4fdd5
commit
0f96b8518f
12
pkg/auth/emails/password_reset.txt.tmpl
Normal file
12
pkg/auth/emails/password_reset.txt.tmpl
Normal file
@@ -0,0 +1,12 @@
|
||||
Hi {{.FullName}},
|
||||
|
||||
You have requested a password reset for your Probo account.
|
||||
|
||||
Please click the link below to reset your password:
|
||||
|
||||
{{.ResetURL}}
|
||||
|
||||
If you did not request this password reset, please ignore this email.
|
||||
|
||||
Thanks,
|
||||
Probo Team
|
||||
12
pkg/auth/emails/signup_confirmation.txt.tmpl
Normal file
12
pkg/auth/emails/signup_confirmation.txt.tmpl
Normal file
@@ -0,0 +1,12 @@
|
||||
Hi {{.FullName}},
|
||||
|
||||
Thanks for joining Probo!
|
||||
|
||||
Please confirm your email address by clicking the link below:
|
||||
|
||||
{{.ConfirmationURL}}
|
||||
|
||||
If you did not sign up for Probo, please ignore this email.
|
||||
|
||||
Thanks,
|
||||
Probo Team
|
||||
602
pkg/auth/service.go
Normal file
602
pkg/auth/service.go
Normal file
@@ -0,0 +1,602 @@
|
||||
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
_ "embed"
|
||||
"errors"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"net/mail"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"github.com/getprobo/probo/pkg/coredata"
|
||||
"github.com/getprobo/probo/pkg/crypto/passwdhash"
|
||||
"github.com/getprobo/probo/pkg/gid"
|
||||
"github.com/getprobo/probo/pkg/statelesstoken"
|
||||
"go.gearno.de/kit/pg"
|
||||
)
|
||||
|
||||
type (
|
||||
// Service handles ONLY user authentication and management
|
||||
// No organization-related logic - that belongs to authz service
|
||||
Service struct {
|
||||
pg *pg.Client
|
||||
hp *passwdhash.Profile
|
||||
hostname string
|
||||
tokenSecret string
|
||||
disableSignup bool
|
||||
invitationTokenValidity time.Duration
|
||||
}
|
||||
|
||||
ErrInvalidCredentials struct {
|
||||
message string
|
||||
}
|
||||
|
||||
ErrInvalidEmail struct {
|
||||
email string
|
||||
}
|
||||
|
||||
ErrInvalidPassword struct {
|
||||
minLength int
|
||||
maxLength int
|
||||
}
|
||||
|
||||
ErrInvalidFullName struct {
|
||||
fullName string
|
||||
}
|
||||
|
||||
ErrUserAlreadyExists struct {
|
||||
message string
|
||||
}
|
||||
|
||||
ErrSessionNotFound struct {
|
||||
message string
|
||||
}
|
||||
|
||||
ErrSessionExpired struct {
|
||||
message string
|
||||
}
|
||||
|
||||
ErrInvalidTokenType struct {
|
||||
message string
|
||||
}
|
||||
|
||||
ErrSignupDisabled struct{}
|
||||
|
||||
EmailConfirmationData struct {
|
||||
UserID gid.GID `json:"uid"`
|
||||
Email string `json:"email"`
|
||||
}
|
||||
|
||||
InvitationData struct {
|
||||
OrganizationID gid.GID `json:"organization_id"`
|
||||
Email string `json:"email"`
|
||||
FullName string `json:"full_name"`
|
||||
CreatePeople bool `json:"create_people"`
|
||||
}
|
||||
PasswordResetData struct {
|
||||
Email string `json:"email"`
|
||||
}
|
||||
)
|
||||
|
||||
const (
|
||||
TokenTypeEmailConfirmation = "email_confirmation"
|
||||
TokenTypePasswordReset = "password_reset"
|
||||
)
|
||||
|
||||
var (
|
||||
//go:embed emails/signup_confirmation.txt.tmpl
|
||||
signupEmailTemplateData string
|
||||
signupEmailTemplate = template.Must(template.New("signup").Parse(signupEmailTemplateData))
|
||||
signupEmailSubject = "Confirm your email address"
|
||||
|
||||
//go:embed emails/password_reset.txt.tmpl
|
||||
passwordResetEmailTemplateData string
|
||||
passwordResetEmailTemplate = template.Must(template.New("password_reset").Parse(passwordResetEmailTemplateData))
|
||||
passwordResetEmailSubject = "Reset your password"
|
||||
)
|
||||
|
||||
func (e ErrInvalidCredentials) Error() string {
|
||||
return e.message
|
||||
}
|
||||
|
||||
func (e ErrUserAlreadyExists) Error() string {
|
||||
return e.message
|
||||
}
|
||||
|
||||
func (e ErrSessionNotFound) Error() string {
|
||||
return e.message
|
||||
}
|
||||
|
||||
func (e ErrSessionExpired) Error() string {
|
||||
return e.message
|
||||
}
|
||||
|
||||
func (e ErrInvalidEmail) Error() string {
|
||||
return fmt.Sprintf("invalid email: %s", e.email)
|
||||
}
|
||||
|
||||
func (e ErrInvalidPassword) Error() string {
|
||||
return fmt.Sprintf("invalid password: the length must be between %d and %d characters", e.minLength, e.maxLength)
|
||||
}
|
||||
|
||||
func (e ErrInvalidFullName) Error() string {
|
||||
return fmt.Sprintf("invalid full name: %s", e.fullName)
|
||||
}
|
||||
|
||||
func (e ErrInvalidTokenType) Error() string {
|
||||
return e.message
|
||||
}
|
||||
|
||||
func (e ErrSignupDisabled) Error() string {
|
||||
return "signup is disabled, contact the owner of the Probo instance"
|
||||
}
|
||||
|
||||
func NewService(
|
||||
ctx context.Context,
|
||||
pgClient *pg.Client,
|
||||
hp *passwdhash.Profile,
|
||||
tokenSecret string,
|
||||
hostname string,
|
||||
disableSignup bool,
|
||||
invitationTokenValidity time.Duration,
|
||||
) (*Service, error) {
|
||||
return &Service{
|
||||
pg: pgClient,
|
||||
hp: hp,
|
||||
hostname: hostname,
|
||||
tokenSecret: tokenSecret,
|
||||
disableSignup: disableSignup,
|
||||
invitationTokenValidity: invitationTokenValidity,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s Service) ForgetPassword(
|
||||
ctx context.Context,
|
||||
email string,
|
||||
) error {
|
||||
// Always generate a new token to avoid timing attacks and leaking information
|
||||
// about existing emails
|
||||
passwordResetToken, err := statelesstoken.NewToken(
|
||||
s.tokenSecret,
|
||||
TokenTypePasswordReset,
|
||||
1*time.Hour,
|
||||
PasswordResetData{Email: email},
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot generate password reset token: %w", err)
|
||||
}
|
||||
|
||||
resetPasswordUrl := url.URL{
|
||||
Scheme: "https",
|
||||
Host: s.hostname,
|
||||
Path: "/auth/reset-password",
|
||||
RawQuery: url.Values{
|
||||
"token": []string{passwordResetToken},
|
||||
}.Encode(),
|
||||
}
|
||||
|
||||
return s.pg.WithConn(
|
||||
ctx,
|
||||
func(conn pg.Conn) error {
|
||||
user := &coredata.User{}
|
||||
if err := user.LoadByEmail(ctx, conn, email); err != nil {
|
||||
var errUserNotFound *coredata.ErrUserNotFound
|
||||
if errors.As(err, &errUserNotFound) {
|
||||
return nil // Don't leak information about non-existent users
|
||||
}
|
||||
return fmt.Errorf("cannot load user: %w", err)
|
||||
}
|
||||
|
||||
body := bytes.NewBuffer(nil)
|
||||
err = passwordResetEmailTemplate.Execute(
|
||||
body,
|
||||
map[string]string{
|
||||
"FullName": user.FullName,
|
||||
"ResetURL": resetPasswordUrl.String(),
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot execute password reset template: %w", err)
|
||||
}
|
||||
|
||||
passwordResetEmail := coredata.NewEmail(
|
||||
user.FullName,
|
||||
email,
|
||||
passwordResetEmailSubject,
|
||||
body.String(),
|
||||
)
|
||||
if err := passwordResetEmail.Insert(ctx, conn); err != nil {
|
||||
return fmt.Errorf("cannot insert email: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
func (s Service) SignUp(
|
||||
ctx context.Context,
|
||||
emailAddress string,
|
||||
password string,
|
||||
fullName string,
|
||||
) (*coredata.User, *coredata.Session, error) {
|
||||
if s.disableSignup {
|
||||
return nil, nil, &ErrSignupDisabled{}
|
||||
}
|
||||
|
||||
if _, err := mail.ParseAddress(emailAddress); err != nil {
|
||||
return nil, nil, &ErrInvalidEmail{emailAddress}
|
||||
}
|
||||
|
||||
if len(password) < 8 || len(password) > 128 {
|
||||
return nil, nil, &ErrInvalidPassword{minLength: 8, maxLength: 128}
|
||||
}
|
||||
|
||||
if fullName == "" {
|
||||
return nil, nil, &ErrInvalidFullName{fullName}
|
||||
}
|
||||
|
||||
hashedPassword, err := s.hp.HashPassword([]byte(password))
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("cannot hash password: %w", err)
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
user := &coredata.User{
|
||||
ID: gid.New(gid.NilTenant, coredata.UserEntityType),
|
||||
EmailAddress: emailAddress,
|
||||
HashedPassword: hashedPassword,
|
||||
EmailAddressVerified: false,
|
||||
FullName: fullName,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
|
||||
session := &coredata.Session{
|
||||
ID: gid.New(gid.NilTenant, coredata.SessionEntityType),
|
||||
UserID: user.ID,
|
||||
Data: coredata.SessionData{},
|
||||
ExpiredAt: now.Add(24 * time.Hour * 7), // 7 days,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
|
||||
err = s.pg.WithTx(
|
||||
ctx,
|
||||
func(tx pg.Conn) error {
|
||||
if err := user.Insert(ctx, tx); err != nil {
|
||||
var errUserAlreadyExists *coredata.ErrUserAlreadyExists
|
||||
if errors.As(err, &errUserAlreadyExists) {
|
||||
return &ErrUserAlreadyExists{errUserAlreadyExists.Error()}
|
||||
}
|
||||
return fmt.Errorf("cannot insert user: %w", err)
|
||||
}
|
||||
|
||||
confirmationToken, err := statelesstoken.NewToken(
|
||||
s.tokenSecret,
|
||||
TokenTypeEmailConfirmation,
|
||||
24*time.Hour,
|
||||
EmailConfirmationData{UserID: user.ID, Email: user.EmailAddress},
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot generate confirmation token: %w", err)
|
||||
}
|
||||
|
||||
confirmationUrl := url.URL{
|
||||
Scheme: "https",
|
||||
Host: s.hostname,
|
||||
Path: "/auth/confirm-email",
|
||||
RawQuery: url.Values{
|
||||
"token": []string{confirmationToken},
|
||||
}.Encode(),
|
||||
}
|
||||
|
||||
body := bytes.NewBuffer(nil)
|
||||
err = signupEmailTemplate.Execute(
|
||||
body,
|
||||
map[string]string{
|
||||
"FullName": user.FullName,
|
||||
"ConfirmationURL": confirmationUrl.String(),
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot execute signup template: %w", err)
|
||||
}
|
||||
|
||||
confirmationEmail := coredata.NewEmail(
|
||||
user.FullName,
|
||||
user.EmailAddress,
|
||||
signupEmailSubject,
|
||||
body.String(),
|
||||
)
|
||||
|
||||
if err := confirmationEmail.Insert(ctx, tx); err != nil {
|
||||
return fmt.Errorf("cannot insert email: %w", err)
|
||||
}
|
||||
|
||||
if err := session.Insert(ctx, tx); err != nil {
|
||||
return fmt.Errorf("cannot insert session: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
return user, session, nil
|
||||
}
|
||||
|
||||
func (s Service) SignIn(
|
||||
ctx context.Context,
|
||||
emailAddress string,
|
||||
password string,
|
||||
) (*coredata.Session, *coredata.User, error) {
|
||||
if _, err := mail.ParseAddress(emailAddress); err != nil {
|
||||
return nil, nil, &ErrInvalidCredentials{"invalid email or password"}
|
||||
}
|
||||
|
||||
user := &coredata.User{}
|
||||
session := &coredata.Session{}
|
||||
|
||||
err := s.pg.WithTx(
|
||||
ctx,
|
||||
func(tx pg.Conn) error {
|
||||
if err := user.LoadByEmail(ctx, tx, emailAddress); err != nil {
|
||||
var errUserNotFound *coredata.ErrUserNotFound
|
||||
if errors.As(err, &errUserNotFound) {
|
||||
return &ErrInvalidCredentials{"invalid email or password"}
|
||||
}
|
||||
return fmt.Errorf("cannot load user by email: %w", err)
|
||||
}
|
||||
|
||||
match, err := s.hp.ComparePasswordAndHash([]byte(password), user.HashedPassword)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot verify password: %w", err)
|
||||
}
|
||||
if !match {
|
||||
return &ErrInvalidCredentials{"invalid email or password"}
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
session = &coredata.Session{
|
||||
ID: gid.New(gid.NilTenant, coredata.SessionEntityType),
|
||||
UserID: user.ID,
|
||||
Data: coredata.SessionData{},
|
||||
ExpiredAt: now.Add(24 * time.Hour * 7), // 7 days
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
|
||||
if err := session.Insert(ctx, tx); err != nil {
|
||||
return fmt.Errorf("cannot insert session: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
return session, user, nil
|
||||
}
|
||||
|
||||
func (s Service) SignOut(ctx context.Context, sessionID gid.GID) error {
|
||||
return s.pg.WithConn(
|
||||
ctx,
|
||||
func(conn pg.Conn) error {
|
||||
session := &coredata.Session{}
|
||||
if err := session.LoadByID(ctx, conn, sessionID); err != nil {
|
||||
return &ErrSessionNotFound{"session not found"}
|
||||
}
|
||||
|
||||
if err := coredata.DeleteSession(ctx, conn, sessionID); err != nil {
|
||||
return fmt.Errorf("cannot delete session: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
func (s Service) GetSession(ctx context.Context, sessionID gid.GID) (*coredata.Session, error) {
|
||||
session := &coredata.Session{}
|
||||
|
||||
err := s.pg.WithConn(
|
||||
ctx,
|
||||
func(conn pg.Conn) error {
|
||||
if err := session.LoadByID(ctx, conn, sessionID); err != nil {
|
||||
return &ErrSessionNotFound{"session not found"}
|
||||
}
|
||||
|
||||
if time.Now().After(session.ExpiredAt) {
|
||||
// Clean up expired session
|
||||
_ = coredata.DeleteSession(ctx, conn, sessionID)
|
||||
return &ErrSessionExpired{"session expired"}
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return session, nil
|
||||
}
|
||||
|
||||
func (s Service) GetUserByID(ctx context.Context, userID gid.GID) (*coredata.User, error) {
|
||||
user := &coredata.User{}
|
||||
|
||||
err := s.pg.WithConn(
|
||||
ctx,
|
||||
func(conn pg.Conn) error {
|
||||
if err := user.LoadByID(ctx, conn, userID); err != nil {
|
||||
return fmt.Errorf("cannot load user by ID: %w", err)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func (s Service) GetUserByEmail(ctx context.Context, email string) (*coredata.User, error) {
|
||||
user := &coredata.User{}
|
||||
|
||||
err := s.pg.WithConn(
|
||||
ctx,
|
||||
func(conn pg.Conn) error {
|
||||
if err := user.LoadByEmail(ctx, conn, email); err != nil {
|
||||
return fmt.Errorf("cannot load user by email: %w", err)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func (s Service) GetUserBySession(ctx context.Context, sessionID gid.GID) (*coredata.User, error) {
|
||||
session, err := s.GetSession(ctx, sessionID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return s.GetUserByID(ctx, session.UserID)
|
||||
}
|
||||
|
||||
func (s Service) UpdateSession(ctx context.Context, sessionID gid.GID) (*coredata.Session, error) {
|
||||
session := &coredata.Session{}
|
||||
|
||||
err := s.pg.WithTx(
|
||||
ctx,
|
||||
func(tx pg.Conn) error {
|
||||
if err := session.LoadByID(ctx, tx, sessionID); err != nil {
|
||||
return &ErrSessionNotFound{"session not found"}
|
||||
}
|
||||
|
||||
if time.Now().After(session.ExpiredAt) {
|
||||
return &ErrSessionExpired{"session expired"}
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
session.ExpiredAt = now.Add(24 * time.Hour * 7) // Extend by 7 days
|
||||
session.UpdatedAt = now
|
||||
if err := session.Update(ctx, tx); err != nil {
|
||||
return fmt.Errorf("cannot update session: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return session, nil
|
||||
}
|
||||
|
||||
func (s Service) ConfirmEmail(ctx context.Context, tokenString string) error {
|
||||
payload, err := statelesstoken.ValidateToken[EmailConfirmationData](
|
||||
s.tokenSecret,
|
||||
TokenTypeEmailConfirmation,
|
||||
tokenString,
|
||||
)
|
||||
if err != nil {
|
||||
return &ErrInvalidTokenType{"invalid confirmation token"}
|
||||
}
|
||||
emailConfirmationData := payload.Data
|
||||
|
||||
return s.pg.WithTx(
|
||||
ctx,
|
||||
func(tx pg.Conn) error {
|
||||
user := &coredata.User{}
|
||||
if err := user.LoadByID(ctx, tx, emailConfirmationData.UserID); err != nil {
|
||||
return fmt.Errorf("cannot load user: %w", err)
|
||||
}
|
||||
|
||||
if user.EmailAddressVerified {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := user.UpdateEmailVerification(ctx, tx, true); err != nil {
|
||||
return fmt.Errorf("cannot update user email verification: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
func (s Service) ResetPassword(ctx context.Context, tokenString string, newPassword string) error {
|
||||
payload, err := statelesstoken.ValidateToken[PasswordResetData](
|
||||
s.tokenSecret,
|
||||
TokenTypePasswordReset,
|
||||
tokenString,
|
||||
)
|
||||
if err != nil {
|
||||
return &ErrInvalidTokenType{"invalid reset token"}
|
||||
}
|
||||
passwordResetData := payload.Data
|
||||
|
||||
if len(newPassword) < 8 || len(newPassword) > 128 {
|
||||
return &ErrInvalidPassword{minLength: 8, maxLength: 128}
|
||||
}
|
||||
|
||||
hashedPassword, err := s.hp.HashPassword([]byte(newPassword))
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot hash password: %w", err)
|
||||
}
|
||||
|
||||
return s.pg.WithTx(
|
||||
ctx,
|
||||
func(tx pg.Conn) error {
|
||||
user := &coredata.User{}
|
||||
if err := user.LoadByEmail(ctx, tx, passwordResetData.Email); err != nil {
|
||||
var errUserNotFound *coredata.ErrUserNotFound
|
||||
if errors.As(err, &errUserNotFound) {
|
||||
return nil // Don't leak information about non-existent users
|
||||
}
|
||||
return fmt.Errorf("cannot load user: %w", err)
|
||||
}
|
||||
|
||||
if err := user.UpdatePassword(ctx, tx, hashedPassword); err != nil {
|
||||
return fmt.Errorf("cannot update password: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
)
|
||||
}
|
||||
10
pkg/authz/emails/invitation.txt.tmpl
Normal file
10
pkg/authz/emails/invitation.txt.tmpl
Normal file
@@ -0,0 +1,10 @@
|
||||
Hi {{.FullName}},
|
||||
|
||||
You have been invited to join organization {{.OrganizationName}}. Please click the link below to accept the invitation:
|
||||
|
||||
{{.InvitationURL}}
|
||||
|
||||
If you don't want to accept the invitation, you can ignore this email.
|
||||
|
||||
Thanks,
|
||||
Probo Team
|
||||
559
pkg/authz/service.go
Normal file
559
pkg/authz/service.go
Normal file
@@ -0,0 +1,559 @@
|
||||
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package authz
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
_ "embed"
|
||||
"errors"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"time"
|
||||
|
||||
"github.com/getprobo/probo/pkg/coredata"
|
||||
"github.com/getprobo/probo/pkg/gid"
|
||||
"github.com/getprobo/probo/pkg/page"
|
||||
"github.com/getprobo/probo/pkg/statelesstoken"
|
||||
"go.gearno.de/kit/pg"
|
||||
)
|
||||
|
||||
type (
|
||||
// Service handles all authorization logic including organization
|
||||
// membership and permissions. This service is completely independent
|
||||
// of authentication methods.
|
||||
Service struct {
|
||||
pg *pg.Client
|
||||
hostname string
|
||||
tokenSecret string
|
||||
invitationTokenValidity time.Duration
|
||||
}
|
||||
|
||||
Role string
|
||||
)
|
||||
|
||||
const (
|
||||
RoleOwner Role = "OWNER"
|
||||
RoleAdmin Role = "ADMIN"
|
||||
RoleMember Role = "MEMBER"
|
||||
RoleViewer Role = "VIEWER"
|
||||
)
|
||||
|
||||
const (
|
||||
TokenTypeOrganizationInvitation = "organization_invitation"
|
||||
)
|
||||
|
||||
var (
|
||||
//go:embed emails/invitation.txt.tmpl
|
||||
invitationEmailBodyData string
|
||||
|
||||
invitationEmailBodyTemplate = template.Must(template.New("invitation").Parse(invitationEmailBodyData))
|
||||
invitationEmailSubject = "Invitation to join organization"
|
||||
)
|
||||
|
||||
func NewService(
|
||||
ctx context.Context,
|
||||
pgClient *pg.Client,
|
||||
hostname string,
|
||||
tokenSecret string,
|
||||
invitationTokenValidity time.Duration,
|
||||
) (*Service, error) {
|
||||
return &Service{
|
||||
pg: pgClient,
|
||||
hostname: hostname,
|
||||
tokenSecret: tokenSecret,
|
||||
invitationTokenValidity: invitationTokenValidity,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) GetAllUserOrganizations(
|
||||
ctx context.Context,
|
||||
userID gid.GID,
|
||||
) ([]*coredata.Organization, error) {
|
||||
var organizations []*coredata.Organization
|
||||
|
||||
err := s.pg.WithConn(ctx, func(conn pg.Conn) error {
|
||||
var organizationList coredata.Organizations
|
||||
if err := organizationList.LoadAllByUserID(ctx, conn, userID); err != nil {
|
||||
return fmt.Errorf("failed to load user organizations: %w", err)
|
||||
}
|
||||
|
||||
organizations = organizationList
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
return organizations, err
|
||||
}
|
||||
|
||||
func (s *Service) GetUserOrganizations(
|
||||
ctx context.Context,
|
||||
userID gid.GID,
|
||||
cursor *page.Cursor[coredata.OrganizationOrderField],
|
||||
) ([]*coredata.Organization, error) {
|
||||
var organizations coredata.Organizations
|
||||
|
||||
err := s.pg.WithConn(ctx, func(conn pg.Conn) error {
|
||||
if err := organizations.LoadByUserID(ctx, conn, userID, cursor); err != nil {
|
||||
return fmt.Errorf("failed to load user organizations: %w", err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
return organizations, err
|
||||
}
|
||||
|
||||
func (s *Service) GetAllOrganizationInvitations(
|
||||
ctx context.Context,
|
||||
orgID gid.GID,
|
||||
cursor *page.Cursor[coredata.InvitationOrderField],
|
||||
) (*page.Page[*coredata.Invitation, coredata.InvitationOrderField], error) {
|
||||
var invitations coredata.Invitations
|
||||
|
||||
err := s.pg.WithConn(
|
||||
ctx,
|
||||
func(conn pg.Conn) error {
|
||||
if err := invitations.LoadByOrganizationID(ctx, conn, orgID, cursor); err != nil {
|
||||
return fmt.Errorf("failed to load organization invitations: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return page.NewPage(invitations, cursor), nil
|
||||
}
|
||||
|
||||
func (s *Service) CountOrganizationInvitations(
|
||||
ctx context.Context,
|
||||
orgID gid.GID,
|
||||
) (int, error) {
|
||||
var count int
|
||||
err := s.pg.WithConn(
|
||||
ctx,
|
||||
func(conn pg.Conn) error {
|
||||
var invitations coredata.Invitations
|
||||
var err error
|
||||
count, err = invitations.CountByOrganizationID(ctx, conn, orgID)
|
||||
return err
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to count invitations: %w", err)
|
||||
}
|
||||
|
||||
return count, nil
|
||||
}
|
||||
|
||||
func (s *Service) DeleteInvitation(
|
||||
ctx context.Context,
|
||||
invitationID gid.GID,
|
||||
) error {
|
||||
return s.pg.WithConn(
|
||||
ctx,
|
||||
func(conn pg.Conn) error {
|
||||
invitation := &coredata.Invitation{}
|
||||
if err := invitation.LoadByID(ctx, conn, invitationID); err != nil {
|
||||
return fmt.Errorf("failed to load invitation: %w", err)
|
||||
}
|
||||
|
||||
if err := invitation.Delete(ctx, conn); err != nil {
|
||||
return fmt.Errorf("failed to delete invitation: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
func (s *Service) GetAllOrganizationMemberships(
|
||||
ctx context.Context,
|
||||
orgID gid.GID,
|
||||
cursor *page.Cursor[coredata.MembershipOrderField],
|
||||
) (*page.Page[*coredata.Membership, coredata.MembershipOrderField], error) {
|
||||
var memberships coredata.Memberships
|
||||
|
||||
err := s.pg.WithConn(
|
||||
ctx,
|
||||
func(conn pg.Conn) error {
|
||||
if err := memberships.LoadByOrganizationID(ctx, conn, orgID, cursor); err != nil {
|
||||
return fmt.Errorf("failed to load organization memberships: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return page.NewPage(memberships, cursor), nil
|
||||
}
|
||||
|
||||
func (s *Service) CountOrganizationMemberships(
|
||||
ctx context.Context,
|
||||
orgID gid.GID,
|
||||
) (int, error) {
|
||||
var count int
|
||||
err := s.pg.WithConn(
|
||||
ctx,
|
||||
func(conn pg.Conn) error {
|
||||
var memberships coredata.Memberships
|
||||
var err error
|
||||
count, err = memberships.CountByOrganizationID(ctx, conn, orgID)
|
||||
return err
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to count memberships: %w", err)
|
||||
}
|
||||
|
||||
return count, nil
|
||||
}
|
||||
|
||||
func (s *Service) CanUserAccessOrganization(
|
||||
ctx context.Context,
|
||||
userID gid.GID,
|
||||
orgID gid.GID,
|
||||
) (bool, error) {
|
||||
membership := &coredata.Membership{}
|
||||
|
||||
haveAccess := false
|
||||
|
||||
err := s.pg.WithConn(
|
||||
ctx,
|
||||
func(conn pg.Conn) error {
|
||||
if err := membership.LoadByUserAndOrg(ctx, conn, userID, orgID); err != nil {
|
||||
if _, ok := err.(coredata.ErrMembershipNotFound); ok {
|
||||
return nil // Not an error, just no access
|
||||
}
|
||||
return fmt.Errorf("failed to check organization access: %w", err)
|
||||
}
|
||||
haveAccess = true
|
||||
return nil
|
||||
},
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
return haveAccess, nil
|
||||
}
|
||||
|
||||
func (s *Service) GetUserRoleInOrganization(
|
||||
ctx context.Context,
|
||||
userID gid.GID,
|
||||
orgID gid.GID,
|
||||
) (string, error) {
|
||||
membership := &coredata.Membership{}
|
||||
|
||||
err := s.pg.WithConn(
|
||||
ctx,
|
||||
func(conn pg.Conn) error {
|
||||
if err := membership.LoadByUserAndOrg(ctx, conn, userID, orgID); err != nil {
|
||||
return fmt.Errorf("failed to get user role: %w", err)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return membership.Role, nil
|
||||
}
|
||||
|
||||
func (s *Service) RemoveMemberFromOrganization(
|
||||
ctx context.Context,
|
||||
orgID gid.GID,
|
||||
memberID gid.GID,
|
||||
) error {
|
||||
membership := &coredata.Membership{}
|
||||
|
||||
return s.pg.WithTx(
|
||||
ctx,
|
||||
func(tx pg.Conn) error {
|
||||
if err := membership.LoadByID(ctx, tx, memberID); err != nil {
|
||||
return fmt.Errorf("failed to load membership: %w", err)
|
||||
}
|
||||
|
||||
if membership.OrganizationID != orgID {
|
||||
return fmt.Errorf("membership does not belong to organization")
|
||||
}
|
||||
|
||||
if err := membership.Delete(ctx, tx); err != nil {
|
||||
return fmt.Errorf("failed to delete membership: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
func (s *Service) AddUserToOrganization(
|
||||
ctx context.Context,
|
||||
userID gid.GID,
|
||||
orgID gid.GID,
|
||||
role string,
|
||||
) error {
|
||||
membership := &coredata.Membership{
|
||||
UserID: userID,
|
||||
OrganizationID: orgID,
|
||||
Role: role,
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
|
||||
return s.pg.WithConn(
|
||||
ctx,
|
||||
func(conn pg.Conn) error {
|
||||
if err := membership.Create(ctx, conn); err != nil {
|
||||
return fmt.Errorf("failed to add user to organization: %w", err)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
func (s *Service) UpdateUserRole(
|
||||
ctx context.Context,
|
||||
userID gid.GID,
|
||||
orgID gid.GID,
|
||||
newRole string,
|
||||
) error {
|
||||
return s.pg.WithTx(
|
||||
ctx,
|
||||
func(tx pg.Conn) error {
|
||||
membership := &coredata.Membership{}
|
||||
if err := membership.LoadByUserAndOrg(ctx, tx, userID, orgID); err != nil {
|
||||
return fmt.Errorf("failed to find membership: %w", err)
|
||||
}
|
||||
|
||||
membership.Role = newRole
|
||||
membership.UpdatedAt = time.Now()
|
||||
|
||||
if err := membership.Update(ctx, tx); err != nil {
|
||||
return fmt.Errorf("failed to update user role: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
func (s *Service) InviteUserToOrganization(
|
||||
ctx context.Context,
|
||||
organizationID gid.GID,
|
||||
emailAddress string,
|
||||
fullName string,
|
||||
role string,
|
||||
) (*coredata.Invitation, error) {
|
||||
var invitation *coredata.Invitation
|
||||
|
||||
err := s.pg.WithTx(ctx, func(tx pg.Conn) error {
|
||||
user := &coredata.User{}
|
||||
userExists := true
|
||||
if err := user.LoadByEmail(ctx, tx, emailAddress); err != nil {
|
||||
var userNotFound *coredata.ErrUserNotFound
|
||||
if errors.As(err, &userNotFound) {
|
||||
userExists = false
|
||||
} else {
|
||||
return fmt.Errorf("failed to check if user exists: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
organization := &coredata.Organization{}
|
||||
scope := coredata.NewScope(organizationID.TenantID())
|
||||
if err := organization.LoadByID(ctx, tx, scope, organizationID); err != nil {
|
||||
return fmt.Errorf("failed to load organization: %w", err)
|
||||
}
|
||||
|
||||
invitationID := gid.New(organizationID.TenantID(), coredata.InvitationEntityType)
|
||||
now := time.Now()
|
||||
invitation = &coredata.Invitation{
|
||||
ID: invitationID,
|
||||
OrganizationID: organizationID,
|
||||
Email: emailAddress,
|
||||
FullName: fullName,
|
||||
Role: role,
|
||||
ExpiresAt: now.Add(s.invitationTokenValidity),
|
||||
CreatedAt: now,
|
||||
}
|
||||
|
||||
if userExists {
|
||||
membership := &coredata.Membership{
|
||||
UserID: user.ID,
|
||||
OrganizationID: organizationID,
|
||||
Role: role,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
if err := membership.Create(ctx, tx); err != nil {
|
||||
return fmt.Errorf("failed to add user to organization: %w", err)
|
||||
}
|
||||
|
||||
invitation.AcceptedAt = &now
|
||||
} else {
|
||||
invitationData := coredata.InvitationData{
|
||||
InvitationID: invitationID,
|
||||
OrganizationID: organizationID,
|
||||
Email: emailAddress,
|
||||
FullName: fullName,
|
||||
Role: role,
|
||||
}
|
||||
|
||||
invitationToken, err := statelesstoken.NewToken(
|
||||
s.tokenSecret,
|
||||
TokenTypeOrganizationInvitation,
|
||||
s.invitationTokenValidity,
|
||||
invitationData,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to generate invitation token: %w", err)
|
||||
}
|
||||
|
||||
body := bytes.NewBuffer(nil)
|
||||
err = invitationEmailBodyTemplate.Execute(
|
||||
body,
|
||||
map[string]string{
|
||||
"FullName": fullName,
|
||||
"OrganizationName": organization.Name,
|
||||
"InvitationURL": fmt.Sprintf("https://%s/auth/confirm-invitation?token=%s", s.hostname, invitationToken),
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to execute template: %w", err)
|
||||
}
|
||||
|
||||
email := coredata.NewEmail(
|
||||
fullName,
|
||||
emailAddress,
|
||||
invitationEmailSubject,
|
||||
body.String(),
|
||||
)
|
||||
|
||||
if err := email.Insert(ctx, tx); err != nil {
|
||||
return fmt.Errorf("cannot insert email: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := invitation.Create(ctx, tx); err != nil {
|
||||
return fmt.Errorf("cannot create invitation: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return invitation, nil
|
||||
}
|
||||
|
||||
func (s *Service) AcceptInvitation(
|
||||
ctx context.Context,
|
||||
token string,
|
||||
userID gid.GID,
|
||||
) error {
|
||||
payload, err := statelesstoken.ValidateToken[coredata.InvitationData](
|
||||
s.tokenSecret,
|
||||
TokenTypeOrganizationInvitation,
|
||||
token,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid invitation token: %w", err)
|
||||
}
|
||||
invitationData := payload.Data
|
||||
|
||||
return s.pg.WithTx(
|
||||
ctx,
|
||||
func(tx pg.Conn) error {
|
||||
invitation := &coredata.Invitation{}
|
||||
if err := invitation.LoadByID(ctx, tx, invitationData.InvitationID); err != nil {
|
||||
var errInvitationNotFound *coredata.ErrInvitationNotFound
|
||||
if errors.As(err, &errInvitationNotFound) {
|
||||
return fmt.Errorf("invitation was deleted or no longer exists")
|
||||
}
|
||||
return fmt.Errorf("cannot load invitation: %w", err)
|
||||
}
|
||||
|
||||
if invitation.AcceptedAt != nil {
|
||||
return fmt.Errorf("invitation already accepted")
|
||||
}
|
||||
|
||||
if time.Now().After(invitation.ExpiresAt) {
|
||||
return fmt.Errorf("invitation expired")
|
||||
}
|
||||
|
||||
membership := &coredata.Membership{
|
||||
UserID: userID,
|
||||
OrganizationID: invitation.OrganizationID,
|
||||
Role: invitation.Role,
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
|
||||
if err := membership.Create(ctx, tx); err != nil {
|
||||
return fmt.Errorf("failed to add user to organization: %w", err)
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
invitation.AcceptedAt = &now
|
||||
if err := invitation.Update(ctx, tx); err != nil {
|
||||
return fmt.Errorf("failed to mark invitation as accepted: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// This is a placeholder for future permission system
|
||||
func (s *Service) HasPermission(
|
||||
ctx context.Context,
|
||||
userID gid.GID,
|
||||
orgID gid.GID,
|
||||
resource string,
|
||||
action string,
|
||||
) (bool, error) {
|
||||
// For now, just check if user is a member
|
||||
// In the future, this will check specific permissions based on role
|
||||
return s.CanUserAccessOrganization(ctx, userID, orgID)
|
||||
}
|
||||
|
||||
func (s *Service) ListUserInvitations(
|
||||
ctx context.Context,
|
||||
email string,
|
||||
) ([]*coredata.Invitation, error) {
|
||||
var invitations coredata.Invitations
|
||||
|
||||
err := s.pg.WithConn(
|
||||
ctx,
|
||||
func(conn pg.Conn) error {
|
||||
if err := invitations.LoadByEmail(ctx, conn, email); err != nil {
|
||||
return fmt.Errorf("failed to load invitations: %w", err)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
)
|
||||
|
||||
return invitations, err
|
||||
}
|
||||
@@ -59,4 +59,6 @@ const (
|
||||
TrustCenterReferenceEntityType
|
||||
TrustCenterDocumentAccessEntityType
|
||||
CustomDomainEntityType
|
||||
InvitationEntityType
|
||||
MembershipEntityType
|
||||
)
|
||||
|
||||
280
pkg/coredata/invitation.go
Normal file
280
pkg/coredata/invitation.go
Normal file
@@ -0,0 +1,280 @@
|
||||
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package coredata
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"maps"
|
||||
"time"
|
||||
|
||||
"github.com/getprobo/probo/pkg/gid"
|
||||
"github.com/getprobo/probo/pkg/page"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"go.gearno.de/kit/pg"
|
||||
)
|
||||
|
||||
type (
|
||||
Invitation struct {
|
||||
ID gid.GID `db:"id"`
|
||||
OrganizationID gid.GID `db:"organization_id"`
|
||||
Email string `db:"email"`
|
||||
FullName string `db:"full_name"`
|
||||
Role string `db:"role"`
|
||||
ExpiresAt time.Time `db:"expires_at"`
|
||||
AcceptedAt *time.Time `db:"accepted_at"`
|
||||
CreatedAt time.Time `db:"created_at"`
|
||||
}
|
||||
|
||||
Invitations []*Invitation
|
||||
|
||||
InvitationData struct {
|
||||
InvitationID gid.GID `json:"invitation_id"`
|
||||
OrganizationID gid.GID `json:"organization_id"`
|
||||
Email string `json:"email"`
|
||||
FullName string `json:"full_name"`
|
||||
Role string `json:"role"`
|
||||
}
|
||||
|
||||
ErrInvitationNotFound struct {
|
||||
Token string
|
||||
}
|
||||
)
|
||||
|
||||
func (e ErrInvitationNotFound) Error() string {
|
||||
return fmt.Sprintf("invitation not found: %s", e.Token)
|
||||
}
|
||||
|
||||
func (i Invitation) CursorKey(orderBy InvitationOrderField) page.CursorKey {
|
||||
switch orderBy {
|
||||
case InvitationOrderFieldFullName:
|
||||
return page.NewCursorKey(i.ID, i.FullName)
|
||||
case InvitationOrderFieldEmail:
|
||||
return page.NewCursorKey(i.ID, i.Email)
|
||||
case InvitationOrderFieldRole:
|
||||
return page.NewCursorKey(i.ID, i.Role)
|
||||
case InvitationOrderFieldCreatedAt:
|
||||
return page.NewCursorKey(i.ID, i.CreatedAt)
|
||||
case InvitationOrderFieldExpiresAt:
|
||||
return page.NewCursorKey(i.ID, i.ExpiresAt)
|
||||
case InvitationOrderFieldAcceptedAt:
|
||||
acceptedAt := time.Time{}
|
||||
if i.AcceptedAt != nil {
|
||||
acceptedAt = *i.AcceptedAt
|
||||
}
|
||||
return page.NewCursorKey(i.ID, acceptedAt)
|
||||
}
|
||||
|
||||
panic(fmt.Sprintf("unsupported order by: %s", orderBy))
|
||||
}
|
||||
|
||||
// Tenant id scope is not applied because invitations are managed at the organization level and don't require tenant isolation.
|
||||
func (i *Invitation) Create(ctx context.Context, conn pg.Conn) error {
|
||||
query := `
|
||||
INSERT INTO authz_invitations (
|
||||
id, organization_id, email, full_name, role, expires_at, created_at
|
||||
) VALUES (
|
||||
@id, @organization_id, @email, @full_name, @role, @expires_at, @created_at
|
||||
)
|
||||
`
|
||||
|
||||
args := pgx.StrictNamedArgs{
|
||||
"id": i.ID,
|
||||
"organization_id": i.OrganizationID,
|
||||
"email": i.Email,
|
||||
"full_name": i.FullName,
|
||||
"role": i.Role,
|
||||
"expires_at": i.ExpiresAt,
|
||||
"created_at": i.CreatedAt,
|
||||
}
|
||||
|
||||
_, err := conn.Exec(ctx, query, args)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create invitation: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Tenant id scope is not applied because we want to access invitations across all tenants for authentication purposes.
|
||||
func (i *Invitation) LoadByID(
|
||||
ctx context.Context,
|
||||
conn pg.Conn,
|
||||
id gid.GID,
|
||||
) error {
|
||||
query := `
|
||||
SELECT id, organization_id, email, full_name, role, expires_at, accepted_at, created_at
|
||||
FROM authz_invitations
|
||||
WHERE id = @id
|
||||
`
|
||||
|
||||
args := pgx.StrictNamedArgs{
|
||||
"id": id,
|
||||
}
|
||||
|
||||
rows, err := conn.Query(ctx, query, args)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot query invitation: %w", err)
|
||||
}
|
||||
|
||||
invitation, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[Invitation])
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return ErrInvitationNotFound{Token: id.String()}
|
||||
}
|
||||
return fmt.Errorf("cannot collect invitation: %w", err)
|
||||
}
|
||||
|
||||
*i = invitation
|
||||
return nil
|
||||
}
|
||||
|
||||
// Tenant id scope is not applied because invitations are managed at the organization level and don't require tenant isolation.
|
||||
func (i *Invitation) Update(ctx context.Context, conn pg.Conn) error {
|
||||
query := `
|
||||
UPDATE authz_invitations
|
||||
SET accepted_at = @accepted_at
|
||||
WHERE id = @id
|
||||
`
|
||||
|
||||
args := pgx.StrictNamedArgs{
|
||||
"id": i.ID,
|
||||
"accepted_at": i.AcceptedAt,
|
||||
}
|
||||
|
||||
result, err := conn.Exec(ctx, query, args)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to update invitation: %w", err)
|
||||
}
|
||||
|
||||
if result.RowsAffected() == 0 {
|
||||
return ErrInvitationNotFound{Token: i.ID.String()}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Tenant id scope is not applied because invitations are managed at the organization level and don't require tenant isolation.
|
||||
func (i *Invitation) Delete(ctx context.Context, conn pg.Conn) error {
|
||||
query := `
|
||||
DELETE FROM authz_invitations
|
||||
WHERE id = @id
|
||||
`
|
||||
|
||||
args := pgx.StrictNamedArgs{
|
||||
"id": i.ID,
|
||||
}
|
||||
|
||||
result, err := conn.Exec(ctx, query, args)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete invitation: %w", err)
|
||||
}
|
||||
|
||||
if result.RowsAffected() == 0 {
|
||||
return ErrInvitationNotFound{Token: i.ID.String()}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (i *Invitations) LoadByEmail(
|
||||
ctx context.Context,
|
||||
conn pg.Conn,
|
||||
email string,
|
||||
) error {
|
||||
query := `
|
||||
SELECT id, organization_id, email, full_name, role, expires_at, accepted_at, created_at
|
||||
FROM authz_invitations
|
||||
WHERE email = @email AND accepted_at IS NULL
|
||||
ORDER BY created_at DESC
|
||||
`
|
||||
|
||||
args := pgx.StrictNamedArgs{
|
||||
"email": email,
|
||||
}
|
||||
|
||||
rows, err := conn.Query(ctx, query, args)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot query invitations: %w", err)
|
||||
}
|
||||
|
||||
invitations, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[Invitation])
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot collect invitations: %w", err)
|
||||
}
|
||||
|
||||
*i = invitations
|
||||
return nil
|
||||
}
|
||||
|
||||
func (i *Invitations) LoadByOrganizationID(
|
||||
ctx context.Context,
|
||||
conn pg.Conn,
|
||||
orgID gid.GID,
|
||||
cursor *page.Cursor[InvitationOrderField],
|
||||
) error {
|
||||
query := `
|
||||
SELECT id, organization_id, email, full_name, role, expires_at, accepted_at, created_at
|
||||
FROM authz_invitations
|
||||
WHERE organization_id = @organization_id
|
||||
AND %s
|
||||
`
|
||||
|
||||
query = fmt.Sprintf(query, cursor.SQLFragment())
|
||||
|
||||
args := pgx.StrictNamedArgs{"organization_id": orgID}
|
||||
maps.Copy(args, cursor.SQLArguments())
|
||||
|
||||
rows, err := conn.Query(ctx, query, args)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot query invitations: %w", err)
|
||||
}
|
||||
|
||||
invitations, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[Invitation])
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot collect invitations: %w", err)
|
||||
}
|
||||
|
||||
*i = invitations
|
||||
return nil
|
||||
}
|
||||
|
||||
func (i *Invitations) CountByOrganizationID(
|
||||
ctx context.Context,
|
||||
conn pg.Conn,
|
||||
orgID gid.GID,
|
||||
) (int, error) {
|
||||
q := `
|
||||
SELECT
|
||||
COUNT(*)
|
||||
FROM
|
||||
authz_invitations
|
||||
WHERE
|
||||
organization_id = @organization_id
|
||||
`
|
||||
|
||||
args := pgx.StrictNamedArgs{"organization_id": orgID}
|
||||
|
||||
row := conn.QueryRow(ctx, q, args)
|
||||
|
||||
var count int
|
||||
err := row.Scan(&count)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("cannot count invitations: %w", err)
|
||||
}
|
||||
|
||||
return count, nil
|
||||
}
|
||||
73
pkg/coredata/invitation_order_field.go
Normal file
73
pkg/coredata/invitation_order_field.go
Normal file
@@ -0,0 +1,73 @@
|
||||
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package coredata
|
||||
|
||||
import "fmt"
|
||||
|
||||
// InvitationOrderField defines the fields that can be used to order invitations
|
||||
type InvitationOrderField string
|
||||
|
||||
// InvitationOrderField constants
|
||||
const (
|
||||
InvitationOrderFieldFullName InvitationOrderField = "FULL_NAME"
|
||||
InvitationOrderFieldEmail InvitationOrderField = "EMAIL"
|
||||
InvitationOrderFieldRole InvitationOrderField = "ROLE"
|
||||
InvitationOrderFieldCreatedAt InvitationOrderField = "CREATED_AT"
|
||||
InvitationOrderFieldExpiresAt InvitationOrderField = "EXPIRES_AT"
|
||||
InvitationOrderFieldAcceptedAt InvitationOrderField = "ACCEPTED_AT"
|
||||
)
|
||||
|
||||
func (p InvitationOrderField) Column() string {
|
||||
switch p {
|
||||
case InvitationOrderFieldFullName:
|
||||
return "full_name"
|
||||
case InvitationOrderFieldEmail:
|
||||
return "email"
|
||||
case InvitationOrderFieldRole:
|
||||
return "role"
|
||||
case InvitationOrderFieldCreatedAt:
|
||||
return "created_at"
|
||||
case InvitationOrderFieldExpiresAt:
|
||||
return "expires_at"
|
||||
case InvitationOrderFieldAcceptedAt:
|
||||
return "accepted_at"
|
||||
}
|
||||
|
||||
panic(fmt.Sprintf("unsupported order by: %s", p))
|
||||
}
|
||||
|
||||
func (e InvitationOrderField) IsValid() bool {
|
||||
switch e {
|
||||
case InvitationOrderFieldFullName, InvitationOrderFieldEmail, InvitationOrderFieldRole, InvitationOrderFieldCreatedAt, InvitationOrderFieldExpiresAt, InvitationOrderFieldAcceptedAt:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (e InvitationOrderField) String() string {
|
||||
return string(e)
|
||||
}
|
||||
|
||||
func (e *InvitationOrderField) UnmarshalText(text []byte) error {
|
||||
*e = InvitationOrderField(text)
|
||||
if !e.IsValid() {
|
||||
return fmt.Errorf("%s is not a valid InvitationOrderField", string(text))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e InvitationOrderField) MarshalText() ([]byte, error) {
|
||||
return []byte(e.String()), nil
|
||||
}
|
||||
356
pkg/coredata/membership.go
Normal file
356
pkg/coredata/membership.go
Normal file
@@ -0,0 +1,356 @@
|
||||
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package coredata
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"maps"
|
||||
"time"
|
||||
|
||||
"github.com/getprobo/probo/pkg/gid"
|
||||
"github.com/getprobo/probo/pkg/page"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
"go.gearno.de/kit/pg"
|
||||
)
|
||||
|
||||
type (
|
||||
Membership struct {
|
||||
ID gid.GID `db:"id"`
|
||||
UserID gid.GID `db:"user_id"`
|
||||
OrganizationID gid.GID `db:"organization_id"`
|
||||
Role string `db:"role"`
|
||||
FullName string `db:"full_name"`
|
||||
EmailAddress string `db:"email_address"`
|
||||
CreatedAt time.Time `db:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at"`
|
||||
}
|
||||
|
||||
Memberships []*Membership
|
||||
|
||||
ErrMembershipNotFound struct {
|
||||
UserID gid.GID
|
||||
OrgID gid.GID
|
||||
}
|
||||
|
||||
ErrMembershipAlreadyExists struct {
|
||||
UserID gid.GID
|
||||
OrgID gid.GID
|
||||
}
|
||||
)
|
||||
|
||||
func (e ErrMembershipNotFound) Error() string {
|
||||
return fmt.Sprintf("membership not found for user %s in organization %s", e.UserID, e.OrgID)
|
||||
}
|
||||
|
||||
func (e ErrMembershipAlreadyExists) Error() string {
|
||||
return fmt.Sprintf("membership already exists for user %s in organization %s", e.UserID, e.OrgID)
|
||||
}
|
||||
|
||||
func (m Membership) CursorKey(orderBy MembershipOrderField) page.CursorKey {
|
||||
switch orderBy {
|
||||
case MembershipOrderFieldFullName:
|
||||
return page.NewCursorKey(m.ID, m.FullName)
|
||||
case MembershipOrderFieldEmailAddress:
|
||||
return page.NewCursorKey(m.ID, m.EmailAddress)
|
||||
case MembershipOrderFieldRole:
|
||||
return page.NewCursorKey(m.ID, m.Role)
|
||||
case MembershipOrderFieldCreatedAt:
|
||||
return page.NewCursorKey(m.ID, m.CreatedAt)
|
||||
}
|
||||
|
||||
panic(fmt.Sprintf("unsupported order by: %s", orderBy))
|
||||
}
|
||||
|
||||
// Tenant id scope is not applied because memberships are managed at the organization level and don't require tenant isolation.
|
||||
func (m *Membership) Create(ctx context.Context, conn pg.Conn) error {
|
||||
query := `
|
||||
INSERT INTO authz_memberships (id, user_id, organization_id, role, created_at, updated_at)
|
||||
SELECT
|
||||
generate_gid(decode_base64_unpadded(o.tenant_id), @entity_type),
|
||||
@user_id,
|
||||
@organization_id,
|
||||
@role,
|
||||
@created_at,
|
||||
@updated_at
|
||||
FROM organizations o
|
||||
WHERE o.id = @organization_id
|
||||
`
|
||||
|
||||
args := pgx.StrictNamedArgs{
|
||||
"user_id": m.UserID,
|
||||
"organization_id": m.OrganizationID,
|
||||
"role": m.Role,
|
||||
"created_at": m.CreatedAt,
|
||||
"updated_at": m.UpdatedAt,
|
||||
"entity_type": MembershipEntityType,
|
||||
}
|
||||
|
||||
result, err := conn.Exec(ctx, query, args)
|
||||
if err != nil {
|
||||
var pgErr *pgconn.PgError
|
||||
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
|
||||
return ErrMembershipAlreadyExists{UserID: m.UserID, OrgID: m.OrganizationID}
|
||||
}
|
||||
return fmt.Errorf("failed to create membership: %w", err)
|
||||
}
|
||||
|
||||
if result.RowsAffected() == 0 {
|
||||
return fmt.Errorf("failed to create membership: organization %s not found", m.OrganizationID)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Tenant id scope is not applied because we want to access memberships across all tenants for authentication purposes.
|
||||
func (m *Membership) LoadByID(
|
||||
ctx context.Context,
|
||||
conn pg.Conn,
|
||||
membershipID gid.GID,
|
||||
) error {
|
||||
query := `
|
||||
SELECT
|
||||
m.id,
|
||||
m.user_id,
|
||||
m.organization_id,
|
||||
m.role,
|
||||
u.fullname as full_name,
|
||||
u.email_address,
|
||||
m.created_at,
|
||||
m.updated_at
|
||||
FROM authz_memberships m
|
||||
JOIN users u ON m.user_id = u.id
|
||||
WHERE m.id = @membership_id
|
||||
`
|
||||
|
||||
args := pgx.StrictNamedArgs{
|
||||
"membership_id": membershipID,
|
||||
}
|
||||
|
||||
rows, err := conn.Query(ctx, query, args)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot query membership: %w", err)
|
||||
}
|
||||
|
||||
membership, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[Membership])
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return ErrMembershipNotFound{UserID: gid.GID{}, OrgID: gid.GID{}}
|
||||
}
|
||||
return fmt.Errorf("cannot collect membership: %w", err)
|
||||
}
|
||||
|
||||
*m = membership
|
||||
return nil
|
||||
}
|
||||
|
||||
// Tenant id scope is not applied because we want to access memberships across all tenants for authentication purposes.
|
||||
func (m *Membership) LoadByUserAndOrg(
|
||||
ctx context.Context,
|
||||
conn pg.Conn,
|
||||
userID gid.GID,
|
||||
orgID gid.GID,
|
||||
) error {
|
||||
query := `
|
||||
SELECT
|
||||
m.id,
|
||||
m.user_id,
|
||||
m.organization_id,
|
||||
m.role,
|
||||
u.fullname as full_name,
|
||||
u.email_address,
|
||||
m.created_at,
|
||||
m.updated_at
|
||||
FROM authz_memberships m
|
||||
JOIN users u ON m.user_id = u.id
|
||||
WHERE m.user_id = @user_id AND m.organization_id = @organization_id
|
||||
`
|
||||
|
||||
args := pgx.StrictNamedArgs{
|
||||
"user_id": userID,
|
||||
"organization_id": orgID,
|
||||
}
|
||||
|
||||
rows, err := conn.Query(ctx, query, args)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot query membership: %w", err)
|
||||
}
|
||||
|
||||
membership, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[Membership])
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return ErrMembershipNotFound{UserID: userID, OrgID: orgID}
|
||||
}
|
||||
return fmt.Errorf("cannot collect membership: %w", err)
|
||||
}
|
||||
|
||||
*m = membership
|
||||
return nil
|
||||
}
|
||||
|
||||
// Tenant id scope is not applied because memberships are managed at the organization level and don't require tenant isolation.
|
||||
func (m *Membership) Update(ctx context.Context, conn pg.Conn) error {
|
||||
query := `
|
||||
UPDATE authz_memberships
|
||||
SET role = @role, updated_at = @updated_at
|
||||
WHERE id = @id
|
||||
`
|
||||
|
||||
args := pgx.StrictNamedArgs{
|
||||
"id": m.ID,
|
||||
"role": m.Role,
|
||||
"updated_at": m.UpdatedAt,
|
||||
}
|
||||
|
||||
result, err := conn.Exec(ctx, query, args)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to update membership: %w", err)
|
||||
}
|
||||
|
||||
if result.RowsAffected() == 0 {
|
||||
return ErrMembershipNotFound{UserID: m.UserID, OrgID: m.OrganizationID}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Tenant id scope is not applied because memberships are managed at the organization level and don't require tenant isolation.
|
||||
func (m *Membership) Delete(ctx context.Context, conn pg.Conn) error {
|
||||
query := `
|
||||
DELETE FROM authz_memberships
|
||||
WHERE id = @id
|
||||
`
|
||||
|
||||
args := pgx.StrictNamedArgs{
|
||||
"id": m.ID,
|
||||
}
|
||||
|
||||
result, err := conn.Exec(ctx, query, args)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete membership: %w", err)
|
||||
}
|
||||
|
||||
if result.RowsAffected() == 0 {
|
||||
return ErrMembershipNotFound{UserID: m.UserID, OrgID: m.OrganizationID}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Tenant id scope is not applied because we want to access all user's memberships across tenants for authentication purposes.
|
||||
func (m *Memberships) LoadByUserID(
|
||||
ctx context.Context,
|
||||
conn pg.Conn,
|
||||
userID gid.GID,
|
||||
) error {
|
||||
query := `
|
||||
SELECT
|
||||
m.id,
|
||||
m.user_id,
|
||||
m.organization_id,
|
||||
m.role,
|
||||
u.fullname as full_name,
|
||||
u.email_address,
|
||||
m.created_at,
|
||||
m.updated_at
|
||||
FROM
|
||||
authz_memberships m
|
||||
JOIN users u ON m.user_id = u.id
|
||||
WHERE
|
||||
m.user_id = @user_id
|
||||
ORDER BY
|
||||
m.created_at DESC
|
||||
`
|
||||
|
||||
args := pgx.StrictNamedArgs{"user_id": userID}
|
||||
|
||||
rows, err := conn.Query(ctx, query, args)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot query memberships: %w", err)
|
||||
}
|
||||
|
||||
memberships, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[Membership])
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot collect memberships: %w", err)
|
||||
}
|
||||
|
||||
*m = memberships
|
||||
return nil
|
||||
}
|
||||
|
||||
// Tenant id scope is not applied because we want to access memberships across all tenants for authentication purposes.
|
||||
func (m *Memberships) LoadByOrganizationID(
|
||||
ctx context.Context,
|
||||
conn pg.Conn,
|
||||
organizationID gid.GID,
|
||||
cursor *page.Cursor[MembershipOrderField],
|
||||
) error {
|
||||
query := `
|
||||
SELECT
|
||||
m.id,
|
||||
m.user_id,
|
||||
m.organization_id,
|
||||
m.role,
|
||||
u.fullname as full_name,
|
||||
u.email_address,
|
||||
m.created_at,
|
||||
m.updated_at
|
||||
FROM
|
||||
authz_memberships m
|
||||
JOIN users u ON m.user_id = u.id
|
||||
WHERE
|
||||
m.organization_id = @organization_id
|
||||
AND %s
|
||||
`
|
||||
|
||||
query = fmt.Sprintf(query, cursor.SQLFragment())
|
||||
|
||||
args := pgx.StrictNamedArgs{"organization_id": organizationID}
|
||||
maps.Copy(args, cursor.SQLArguments())
|
||||
|
||||
rows, err := conn.Query(ctx, query, args)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot query memberships: %w", err)
|
||||
}
|
||||
|
||||
memberships, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[Membership])
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot collect memberships: %w", err)
|
||||
}
|
||||
|
||||
*m = memberships
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Memberships) CountByOrganizationID(
|
||||
ctx context.Context,
|
||||
conn pg.Conn,
|
||||
organizationID gid.GID,
|
||||
) (int, error) {
|
||||
query := `
|
||||
SELECT COUNT(*)
|
||||
FROM authz_memberships
|
||||
WHERE organization_id = @organization_id
|
||||
`
|
||||
args := pgx.StrictNamedArgs{"organization_id": organizationID}
|
||||
row := conn.QueryRow(ctx, query, args)
|
||||
var count int
|
||||
if err := row.Scan(&count); err != nil {
|
||||
return 0, fmt.Errorf("cannot count memberships: %w", err)
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
53
pkg/coredata/membership_order_field.go
Normal file
53
pkg/coredata/membership_order_field.go
Normal file
@@ -0,0 +1,53 @@
|
||||
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package coredata
|
||||
|
||||
type (
|
||||
MembershipOrderField string
|
||||
)
|
||||
|
||||
const (
|
||||
MembershipOrderFieldFullName MembershipOrderField = "FULL_NAME"
|
||||
MembershipOrderFieldEmailAddress MembershipOrderField = "EMAIL_ADDRESS"
|
||||
MembershipOrderFieldRole MembershipOrderField = "ROLE"
|
||||
MembershipOrderFieldCreatedAt MembershipOrderField = "CREATED_AT"
|
||||
)
|
||||
|
||||
func (p MembershipOrderField) Column() string {
|
||||
switch p {
|
||||
case MembershipOrderFieldFullName:
|
||||
return "u.fullname"
|
||||
case MembershipOrderFieldEmailAddress:
|
||||
return "u.email_address"
|
||||
case MembershipOrderFieldRole:
|
||||
return "m.role"
|
||||
case MembershipOrderFieldCreatedAt:
|
||||
return "m.created_at"
|
||||
}
|
||||
return string(p)
|
||||
}
|
||||
|
||||
func (p MembershipOrderField) String() string {
|
||||
return string(p)
|
||||
}
|
||||
|
||||
func (p MembershipOrderField) MarshalText() ([]byte, error) {
|
||||
return []byte(p.String()), nil
|
||||
}
|
||||
|
||||
func (p *MembershipOrderField) UnmarshalText(text []byte) error {
|
||||
*p = MembershipOrderField(text)
|
||||
return nil
|
||||
}
|
||||
41
pkg/coredata/migrations/20251006T220024Z.sql
Normal file
41
pkg/coredata/migrations/20251006T220024Z.sql
Normal file
@@ -0,0 +1,41 @@
|
||||
-- Create authorization tables for the new authz service
|
||||
-- This migration creates the new authz tables while keeping the existing users_organizations table
|
||||
-- for backward compatibility during the transition
|
||||
|
||||
-- Create role enum
|
||||
CREATE TYPE authz_role AS ENUM ('OWNER', 'ADMIN', 'MEMBER', 'VIEWER');
|
||||
|
||||
-- Create authz_memberships table with id as primary key
|
||||
CREATE TABLE authz_memberships (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
organization_id TEXT NOT NULL,
|
||||
role authz_role NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL,
|
||||
updated_at TIMESTAMP NOT NULL,
|
||||
UNIQUE (user_id, organization_id)
|
||||
);
|
||||
|
||||
-- Create authz_invitations table
|
||||
CREATE TABLE authz_invitations (
|
||||
id TEXT PRIMARY KEY,
|
||||
organization_id TEXT NOT NULL,
|
||||
email TEXT NOT NULL,
|
||||
full_name TEXT NOT NULL,
|
||||
role authz_role NOT NULL,
|
||||
expires_at TIMESTAMP NOT NULL,
|
||||
accepted_at TIMESTAMP,
|
||||
created_at TIMESTAMP NOT NULL
|
||||
);
|
||||
|
||||
-- Copy data from users_organizations to authz_memberships
|
||||
INSERT INTO authz_memberships (id, user_id, organization_id, role, created_at, updated_at)
|
||||
SELECT
|
||||
generate_gid(decode_base64_unpadded(organizations.tenant_id), 38) as id,
|
||||
users_organizations.user_id,
|
||||
users_organizations.organization_id,
|
||||
'MEMBER'::authz_role as role, -- Default role for existing memberships
|
||||
users_organizations.created_at,
|
||||
users_organizations.created_at as updated_at
|
||||
FROM users_organizations
|
||||
JOIN organizations ON users_organizations.organization_id = organizations.id;
|
||||
@@ -107,7 +107,7 @@ LIMIT 1;
|
||||
}
|
||||
|
||||
// Tenant id scope is not applied in this functions because we want to access all user's organizations.
|
||||
func (o *Organizations) ListForUserID(
|
||||
func (o *Organizations) LoadByUserID(
|
||||
ctx context.Context,
|
||||
conn pg.Conn,
|
||||
userID gid.GID,
|
||||
@@ -118,7 +118,7 @@ WITH user_org AS (
|
||||
SELECT
|
||||
organization_id
|
||||
FROM
|
||||
users_organizations
|
||||
authz_memberships
|
||||
WHERE
|
||||
user_id = @user_id
|
||||
)
|
||||
@@ -163,6 +163,59 @@ WHERE
|
||||
return nil
|
||||
}
|
||||
|
||||
// Tenant id scope is not applied in this function because we want to access all user's organizations.
|
||||
func (o *Organizations) LoadAllByUserID(
|
||||
ctx context.Context,
|
||||
conn pg.Conn,
|
||||
userID gid.GID,
|
||||
) error {
|
||||
q := `
|
||||
WITH user_org AS (
|
||||
SELECT
|
||||
organization_id
|
||||
FROM
|
||||
authz_memberships
|
||||
WHERE
|
||||
user_id = @user_id
|
||||
)
|
||||
SELECT
|
||||
tenant_id,
|
||||
id,
|
||||
name,
|
||||
description,
|
||||
website_url,
|
||||
email,
|
||||
headquarter_address,
|
||||
custom_domain_id,
|
||||
logo_file_id,
|
||||
horizontal_logo_file_id,
|
||||
created_at,
|
||||
updated_at
|
||||
FROM
|
||||
organizations
|
||||
INNER JOIN
|
||||
user_org ON organizations.id = user_org.organization_id
|
||||
ORDER BY
|
||||
created_at DESC
|
||||
`
|
||||
|
||||
args := pgx.StrictNamedArgs{"user_id": userID}
|
||||
|
||||
rows, err := conn.Query(ctx, q, args)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot query organizations: %w", err)
|
||||
}
|
||||
|
||||
organizations, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[Organization])
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot collect organizations: %w", err)
|
||||
}
|
||||
|
||||
*o = organizations
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (o *Organization) Insert(
|
||||
ctx context.Context,
|
||||
conn pg.Conn,
|
||||
|
||||
@@ -47,6 +47,7 @@ func (s Session) CursorKey(orderBy SessionOrderField) page.CursorKey {
|
||||
panic(fmt.Sprintf("unsupported order by: %s", orderBy))
|
||||
}
|
||||
|
||||
// Tenant id scope is not applied because we want to access sessions across all tenants for authentication purposes.
|
||||
func (s *Session) LoadByID(
|
||||
ctx context.Context,
|
||||
conn pg.Conn,
|
||||
|
||||
@@ -138,6 +138,7 @@ LIMIT 1;
|
||||
return nil
|
||||
}
|
||||
|
||||
// Tenant id scope is not applied because we want to access trust centers by slug across all tenants for public access.
|
||||
func (tc *TrustCenter) LoadBySlug(
|
||||
ctx context.Context,
|
||||
conn pg.Conn,
|
||||
|
||||
@@ -87,7 +87,7 @@ FROM
|
||||
users
|
||||
WHERE
|
||||
id IN (
|
||||
SELECT user_id FROM users_organizations WHERE organization_id = @organization_id
|
||||
SELECT user_id FROM authz_memberships WHERE organization_id = @organization_id
|
||||
)
|
||||
AND %s
|
||||
`
|
||||
@@ -112,6 +112,36 @@ WHERE
|
||||
return nil
|
||||
}
|
||||
|
||||
func (u *Users) CountByOrganizationID(
|
||||
ctx context.Context,
|
||||
conn pg.Conn,
|
||||
organizationID gid.GID,
|
||||
) (int, error) {
|
||||
q := `
|
||||
SELECT
|
||||
COUNT(*)
|
||||
FROM
|
||||
users
|
||||
WHERE
|
||||
id IN (
|
||||
SELECT user_id FROM authz_memberships WHERE organization_id = @organization_id
|
||||
)
|
||||
`
|
||||
|
||||
args := pgx.StrictNamedArgs{"organization_id": organizationID}
|
||||
|
||||
row := conn.QueryRow(ctx, q, args)
|
||||
|
||||
var count int
|
||||
err := row.Scan(&count)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("cannot count users: %w", err)
|
||||
}
|
||||
|
||||
return count, nil
|
||||
}
|
||||
|
||||
// Tenant id scope is not applied because we want to access users across all tenants for authentication purposes.
|
||||
func (u *User) LoadByEmail(
|
||||
ctx context.Context,
|
||||
conn pg.Conn,
|
||||
@@ -154,6 +184,7 @@ LIMIT 1;
|
||||
return nil
|
||||
}
|
||||
|
||||
// Tenant id scope is not applied because we want to access users across all tenants for authentication purposes.
|
||||
func (u *User) LoadByID(
|
||||
ctx context.Context,
|
||||
conn pg.Conn,
|
||||
|
||||
@@ -46,6 +46,7 @@ VALUES (@user_id, @organization_id, @created_at)
|
||||
return err
|
||||
}
|
||||
|
||||
// Tenant id scope is not applied because user organizations are managed at the organization level and don't require tenant isolation.
|
||||
func (uo UserOrganization) Delete(ctx context.Context, conn pg.Conn) error {
|
||||
q := `
|
||||
DELETE FROM users_organizations WHERE user_id = @user_id AND organization_id = @organization_id
|
||||
|
||||
@@ -21,6 +21,8 @@ import (
|
||||
|
||||
"github.com/aws/aws-sdk-go-v2/service/s3"
|
||||
"github.com/getprobo/probo/pkg/agents"
|
||||
"github.com/getprobo/probo/pkg/auth"
|
||||
"github.com/getprobo/probo/pkg/authz"
|
||||
"github.com/getprobo/probo/pkg/certmanager"
|
||||
"github.com/getprobo/probo/pkg/coredata"
|
||||
"github.com/getprobo/probo/pkg/crypto/cipher"
|
||||
@@ -28,7 +30,6 @@ import (
|
||||
"github.com/getprobo/probo/pkg/filevalidation"
|
||||
"github.com/getprobo/probo/pkg/gid"
|
||||
"github.com/getprobo/probo/pkg/html2pdf"
|
||||
"github.com/getprobo/probo/pkg/usrmgr"
|
||||
"go.gearno.de/kit/log"
|
||||
"go.gearno.de/kit/pg"
|
||||
"go.gearno.de/x/ref"
|
||||
@@ -56,9 +57,10 @@ type (
|
||||
trustConfig TrustConfig
|
||||
agentConfig agents.Config
|
||||
html2pdfConverter *html2pdf.Converter
|
||||
usrmgr *usrmgr.Service
|
||||
acmeService *certmanager.ACMEService
|
||||
fileManager *filemanager.Service
|
||||
auth *auth.Service
|
||||
authz *authz.Service
|
||||
logger *log.Logger
|
||||
}
|
||||
|
||||
@@ -117,9 +119,10 @@ func NewService(
|
||||
trustConfig TrustConfig,
|
||||
agentConfig agents.Config,
|
||||
html2pdfConverter *html2pdf.Converter,
|
||||
usrmgrService *usrmgr.Service,
|
||||
acmeService *certmanager.ACMEService,
|
||||
fileManagerService *filemanager.Service,
|
||||
authService *auth.Service,
|
||||
authzService *authz.Service,
|
||||
logger *log.Logger,
|
||||
) (*Service, error) {
|
||||
if bucket == "" {
|
||||
@@ -136,9 +139,10 @@ func NewService(
|
||||
trustConfig: trustConfig,
|
||||
agentConfig: agentConfig,
|
||||
html2pdfConverter: html2pdfConverter,
|
||||
usrmgr: usrmgrService,
|
||||
acmeService: acmeService,
|
||||
fileManager: fileManagerService,
|
||||
auth: authService,
|
||||
authz: authzService,
|
||||
logger: logger,
|
||||
}
|
||||
|
||||
@@ -202,7 +206,7 @@ func (s *Service) WithTenant(tenantID gid.TenantID) *TenantService {
|
||||
tenantService.Audits = &AuditService{svc: tenantService}
|
||||
tenantService.Reports = &ReportService{svc: tenantService}
|
||||
tenantService.TrustCenters = &TrustCenterService{svc: tenantService}
|
||||
tenantService.TrustCenterAccesses = &TrustCenterAccessService{svc: tenantService, usrmgr: s.usrmgr}
|
||||
tenantService.TrustCenterAccesses = &TrustCenterAccessService{svc: tenantService}
|
||||
tenantService.TrustCenterReferences = &TrustCenterReferenceService{svc: tenantService}
|
||||
tenantService.Nonconformities = &NonconformityService{svc: tenantService}
|
||||
tenantService.Obligations = &ObligationService{svc: tenantService}
|
||||
|
||||
@@ -25,14 +25,12 @@ import (
|
||||
"github.com/getprobo/probo/pkg/gid"
|
||||
"github.com/getprobo/probo/pkg/page"
|
||||
"github.com/getprobo/probo/pkg/statelesstoken"
|
||||
"github.com/getprobo/probo/pkg/usrmgr"
|
||||
"go.gearno.de/kit/pg"
|
||||
)
|
||||
|
||||
type (
|
||||
TrustCenterAccessService struct {
|
||||
svc *TenantService
|
||||
usrmgr *usrmgr.Service
|
||||
svc *TenantService
|
||||
}
|
||||
|
||||
CreateTrustCenterAccessRequest struct {
|
||||
|
||||
@@ -28,6 +28,8 @@ import (
|
||||
|
||||
"github.com/aws/aws-sdk-go-v2/service/s3"
|
||||
"github.com/getprobo/probo/pkg/agents"
|
||||
"github.com/getprobo/probo/pkg/auth"
|
||||
"github.com/getprobo/probo/pkg/authz"
|
||||
"github.com/getprobo/probo/pkg/awsconfig"
|
||||
"github.com/getprobo/probo/pkg/certmanager"
|
||||
"github.com/getprobo/probo/pkg/connector"
|
||||
@@ -44,7 +46,6 @@ import (
|
||||
"github.com/getprobo/probo/pkg/server"
|
||||
"github.com/getprobo/probo/pkg/server/api"
|
||||
"github.com/getprobo/probo/pkg/trust"
|
||||
"github.com/getprobo/probo/pkg/usrmgr"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"go.gearno.de/kit/httpclient"
|
||||
"go.gearno.de/kit/httpserver"
|
||||
@@ -257,7 +258,7 @@ func (impl *Implm) Run(
|
||||
|
||||
agent := agents.NewAgent(l.Named("agent"), agentConfig)
|
||||
|
||||
usrmgrService, err := usrmgr.NewService(
|
||||
authService, err := auth.NewService(
|
||||
ctx,
|
||||
pgClient,
|
||||
hp,
|
||||
@@ -267,7 +268,18 @@ func (impl *Implm) Run(
|
||||
time.Duration(impl.cfg.Auth.InvitationConfirmationTokenValidity)*time.Second,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot create usrmgr service: %w", err)
|
||||
return fmt.Errorf("cannot create auth service: %w", err)
|
||||
}
|
||||
|
||||
authzService, err := authz.NewService(
|
||||
ctx,
|
||||
pgClient,
|
||||
impl.cfg.Hostname,
|
||||
impl.cfg.Auth.Cookie.Secret,
|
||||
time.Duration(impl.cfg.Auth.InvitationConfirmationTokenValidity)*time.Second,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot create authz service: %w", err)
|
||||
}
|
||||
|
||||
fileManagerService := filemanager.NewService(s3Client)
|
||||
@@ -312,9 +324,10 @@ func (impl *Implm) Run(
|
||||
trustConfig,
|
||||
agentConfig,
|
||||
html2pdfConverter,
|
||||
usrmgrService,
|
||||
acmeService,
|
||||
fileManagerService,
|
||||
authService,
|
||||
authzService,
|
||||
l.Named("probo"),
|
||||
)
|
||||
if err != nil {
|
||||
@@ -327,7 +340,7 @@ func (impl *Implm) Run(
|
||||
impl.cfg.AWS.Bucket,
|
||||
impl.cfg.EncryptionKey,
|
||||
impl.cfg.TrustAuth.TokenSecret,
|
||||
usrmgrService,
|
||||
authService,
|
||||
html2pdfConverter,
|
||||
fileManagerService,
|
||||
)
|
||||
@@ -337,14 +350,15 @@ func (impl *Implm) Run(
|
||||
AllowedOrigins: impl.cfg.Api.Cors.AllowedOrigins,
|
||||
ExtraHeaderFields: impl.cfg.Api.ExtraHeaderFields,
|
||||
Probo: proboService,
|
||||
Usrmgr: usrmgrService,
|
||||
Auth: authService,
|
||||
Authz: authzService,
|
||||
Trust: trustService,
|
||||
ConnectorRegistry: defaultConnectorRegistry,
|
||||
Agent: agent,
|
||||
SafeRedirect: &saferedirect.SafeRedirect{AllowedHost: impl.cfg.Hostname},
|
||||
CustomDomainCname: impl.cfg.CustomDomains.CnameTarget,
|
||||
Logger: l.Named("http.server"),
|
||||
Auth: api.ConsoleAuthConfig{
|
||||
ConsoleAuth: api.ConsoleAuthConfig{
|
||||
CookieName: impl.cfg.Auth.Cookie.Name,
|
||||
CookieDomain: impl.cfg.Auth.Cookie.Domain,
|
||||
SessionDuration: time.Duration(impl.cfg.Auth.Cookie.Duration) * time.Hour,
|
||||
|
||||
@@ -20,13 +20,14 @@ import (
|
||||
|
||||
"time"
|
||||
|
||||
"github.com/getprobo/probo/pkg/auth"
|
||||
"github.com/getprobo/probo/pkg/authz"
|
||||
"github.com/getprobo/probo/pkg/connector"
|
||||
"github.com/getprobo/probo/pkg/probo"
|
||||
"github.com/getprobo/probo/pkg/saferedirect"
|
||||
console_v1 "github.com/getprobo/probo/pkg/server/api/console/v1"
|
||||
trust_v1 "github.com/getprobo/probo/pkg/server/api/trust/v1"
|
||||
"github.com/getprobo/probo/pkg/trust"
|
||||
"github.com/getprobo/probo/pkg/usrmgr"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/cors"
|
||||
"go.gearno.de/kit/httpserver"
|
||||
@@ -55,9 +56,10 @@ type (
|
||||
Config struct {
|
||||
AllowedOrigins []string
|
||||
Probo *probo.Service
|
||||
Usrmgr *usrmgr.Service
|
||||
Auth *auth.Service
|
||||
Authz *authz.Service
|
||||
Trust *trust.Service
|
||||
Auth ConsoleAuthConfig
|
||||
ConsoleAuth ConsoleAuthConfig
|
||||
TrustAuth TrustAuthConfig
|
||||
ConnectorRegistry *connector.ConnectorRegistry
|
||||
SafeRedirect *saferedirect.SafeRedirect
|
||||
@@ -72,8 +74,9 @@ type (
|
||||
)
|
||||
|
||||
var (
|
||||
ErrMissingProboService = errors.New("server configuration requires a valid probo.Service instance")
|
||||
ErrMissingUsrmgrService = errors.New("server configuration requires a valid usrmgr.Service instance")
|
||||
ErrMissingProboService = errors.New("server configuration requires a valid probo.Service instance")
|
||||
ErrMissingAuthService = errors.New("server configuration requires a valid auth.Service instance")
|
||||
ErrMissingAuthzService = errors.New("server configuration requires a valid authz.Service instance")
|
||||
)
|
||||
|
||||
func methodNotAllowed(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -105,20 +108,25 @@ func NewServer(cfg Config) (*Server, error) {
|
||||
return nil, ErrMissingProboService
|
||||
}
|
||||
|
||||
if cfg.Usrmgr == nil {
|
||||
return nil, ErrMissingUsrmgrService
|
||||
if cfg.Auth == nil {
|
||||
return nil, ErrMissingAuthService
|
||||
}
|
||||
|
||||
if cfg.Authz == nil {
|
||||
return nil, ErrMissingAuthzService
|
||||
}
|
||||
|
||||
// Create trust API handler once
|
||||
trustAPIHandler := trust_v1.NewMux(
|
||||
cfg.Logger.Named("trust.v1"),
|
||||
cfg.Usrmgr,
|
||||
cfg.Auth,
|
||||
cfg.Authz,
|
||||
cfg.Trust,
|
||||
console_v1.AuthConfig{
|
||||
CookieName: cfg.Auth.CookieName,
|
||||
CookieDomain: cfg.Auth.CookieDomain,
|
||||
SessionDuration: cfg.Auth.SessionDuration,
|
||||
CookieSecret: cfg.Auth.CookieSecret,
|
||||
CookieName: cfg.ConsoleAuth.CookieName,
|
||||
CookieDomain: cfg.ConsoleAuth.CookieDomain,
|
||||
SessionDuration: cfg.ConsoleAuth.SessionDuration,
|
||||
CookieSecret: cfg.ConsoleAuth.CookieSecret,
|
||||
},
|
||||
trust_v1.TrustAuthConfig{
|
||||
CookieName: cfg.TrustAuth.CookieName,
|
||||
@@ -175,12 +183,13 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
console_v1.NewMux(
|
||||
s.cfg.Logger.Named("console.v1"),
|
||||
s.cfg.Probo,
|
||||
s.cfg.Usrmgr,
|
||||
s.cfg.Auth,
|
||||
s.cfg.Authz,
|
||||
console_v1.AuthConfig{
|
||||
CookieName: s.cfg.Auth.CookieName,
|
||||
CookieDomain: s.cfg.Auth.CookieDomain,
|
||||
SessionDuration: s.cfg.Auth.SessionDuration,
|
||||
CookieSecret: s.cfg.Auth.CookieSecret,
|
||||
CookieName: s.cfg.ConsoleAuth.CookieName,
|
||||
CookieDomain: s.cfg.ConsoleAuth.CookieDomain,
|
||||
SessionDuration: s.cfg.ConsoleAuth.SessionDuration,
|
||||
CookieSecret: s.cfg.ConsoleAuth.CookieSecret,
|
||||
},
|
||||
s.cfg.ConnectorRegistry,
|
||||
s.cfg.SafeRedirect,
|
||||
|
||||
@@ -19,7 +19,7 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/getprobo/probo/pkg/usrmgr"
|
||||
"github.com/getprobo/probo/pkg/auth"
|
||||
"go.gearno.de/kit/httpserver"
|
||||
)
|
||||
|
||||
@@ -33,7 +33,7 @@ type (
|
||||
}
|
||||
)
|
||||
|
||||
func ForgetPasswordHandler(usrmgrSvc *usrmgr.Service, authCfg AuthConfig) http.HandlerFunc {
|
||||
func ForgetPasswordHandler(authSvc *auth.Service, authCfg AuthConfig) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
var req ForgetPasswordRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
@@ -41,7 +41,7 @@ func ForgetPasswordHandler(usrmgrSvc *usrmgr.Service, authCfg AuthConfig) http.H
|
||||
return
|
||||
}
|
||||
|
||||
err := usrmgrSvc.ForgetPassword(r.Context(), req.Email)
|
||||
err := authSvc.ForgetPassword(r.Context(), req.Email)
|
||||
if err != nil {
|
||||
// For security reasons, we don't expose whether an email exists or not
|
||||
httpserver.RenderError(w, http.StatusInternalServerError, fmt.Errorf("cannot process request: %w", err))
|
||||
|
||||
@@ -16,11 +16,14 @@ package console_v1
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/getprobo/probo/pkg/probo"
|
||||
"github.com/getprobo/probo/pkg/usrmgr"
|
||||
"github.com/getprobo/probo/pkg/auth"
|
||||
"github.com/getprobo/probo/pkg/authz"
|
||||
"github.com/getprobo/probo/pkg/coredata"
|
||||
"github.com/getprobo/probo/pkg/statelesstoken"
|
||||
"go.gearno.de/kit/httpserver"
|
||||
)
|
||||
|
||||
@@ -34,7 +37,7 @@ type (
|
||||
}
|
||||
)
|
||||
|
||||
func InvitationConfirmationHandler(usrmgrSvc *usrmgr.Service, proboSvc *probo.Service, authCfg AuthConfig) http.HandlerFunc {
|
||||
func InvitationConfirmationHandler(authSvc *auth.Service, authzSvc *authz.Service, authCfg AuthConfig) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
var req InvitationConfirmationRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
@@ -42,7 +45,32 @@ func InvitationConfirmationHandler(usrmgrSvc *usrmgr.Service, proboSvc *probo.Se
|
||||
return
|
||||
}
|
||||
|
||||
_, err := usrmgrSvc.ConfirmInvitation(r.Context(), req.Token, req.Password)
|
||||
payload, err := statelesstoken.ValidateToken[coredata.InvitationData](
|
||||
authCfg.CookieSecret,
|
||||
authz.TokenTypeOrganizationInvitation,
|
||||
req.Token,
|
||||
)
|
||||
if err != nil {
|
||||
httpserver.RenderError(w, http.StatusBadRequest, fmt.Errorf("invalid invitation token: %w", err))
|
||||
return
|
||||
}
|
||||
|
||||
user, _, err := authSvc.SignUp(r.Context(), payload.Data.Email, req.Password, payload.Data.FullName)
|
||||
if err != nil {
|
||||
var errUserAlreadyExists *auth.ErrUserAlreadyExists
|
||||
if errors.As(err, &errUserAlreadyExists) {
|
||||
user, err = authSvc.GetUserByEmail(r.Context(), payload.Data.Email)
|
||||
if err != nil {
|
||||
httpserver.RenderError(w, http.StatusInternalServerError, fmt.Errorf("failed to load existing user: %w", err))
|
||||
return
|
||||
}
|
||||
} else {
|
||||
httpserver.RenderError(w, http.StatusInternalServerError, fmt.Errorf("failed to create user: %w", err))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
err = authzSvc.AcceptInvitation(r.Context(), req.Token, user.ID)
|
||||
if err != nil {
|
||||
httpserver.RenderError(w, http.StatusInternalServerError, err)
|
||||
return
|
||||
|
||||
@@ -21,7 +21,7 @@ import (
|
||||
|
||||
"errors"
|
||||
|
||||
"github.com/getprobo/probo/pkg/usrmgr"
|
||||
"github.com/getprobo/probo/pkg/auth"
|
||||
"go.gearno.de/kit/httpserver"
|
||||
)
|
||||
|
||||
@@ -36,7 +36,7 @@ type (
|
||||
}
|
||||
)
|
||||
|
||||
func ResetPasswordHandler(usrmgrSvc *usrmgr.Service, authCfg AuthConfig) http.HandlerFunc {
|
||||
func ResetPasswordHandler(authSvc *auth.Service, authCfg AuthConfig) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
var req ResetPasswordRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
@@ -44,10 +44,10 @@ func ResetPasswordHandler(usrmgrSvc *usrmgr.Service, authCfg AuthConfig) http.Ha
|
||||
return
|
||||
}
|
||||
|
||||
err := usrmgrSvc.ResetPassword(r.Context(), req.Token, req.Password)
|
||||
err := authSvc.ResetPassword(r.Context(), req.Token, req.Password)
|
||||
if err != nil {
|
||||
var invalidPasswordErr *usrmgr.ErrInvalidPassword
|
||||
var invalidTokenErr *usrmgr.ErrInvalidTokenType
|
||||
var invalidPasswordErr *auth.ErrInvalidPassword
|
||||
var invalidTokenErr *auth.ErrInvalidTokenType
|
||||
|
||||
if errors.As(err, &invalidPasswordErr) {
|
||||
httpserver.RenderError(w, http.StatusBadRequest, err)
|
||||
|
||||
@@ -29,6 +29,8 @@ import (
|
||||
"github.com/99designs/gqlgen/graphql/handler/extension"
|
||||
"github.com/99designs/gqlgen/graphql/handler/transport"
|
||||
"github.com/99designs/gqlgen/graphql/playground"
|
||||
"github.com/getprobo/probo/pkg/auth"
|
||||
"github.com/getprobo/probo/pkg/authz"
|
||||
"github.com/getprobo/probo/pkg/connector"
|
||||
"github.com/getprobo/probo/pkg/coredata"
|
||||
"github.com/getprobo/probo/pkg/gid"
|
||||
@@ -38,7 +40,6 @@ import (
|
||||
gqlutils "github.com/getprobo/probo/pkg/server/graphql"
|
||||
"github.com/getprobo/probo/pkg/server/session"
|
||||
"github.com/getprobo/probo/pkg/statelesstoken"
|
||||
"github.com/getprobo/probo/pkg/usrmgr"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/vektah/gqlparser/v2/gqlerror"
|
||||
"go.gearno.de/kit/log"
|
||||
@@ -54,7 +55,8 @@ type (
|
||||
|
||||
Resolver struct {
|
||||
proboSvc *probo.Service
|
||||
usrmgrSvc *usrmgr.Service
|
||||
authSvc *auth.Service
|
||||
authzSvc *authz.Service
|
||||
authCfg AuthConfig
|
||||
customDomainCname string
|
||||
}
|
||||
@@ -81,7 +83,8 @@ func UserFromContext(ctx context.Context) *coredata.User {
|
||||
func NewMux(
|
||||
logger *log.Logger,
|
||||
proboSvc *probo.Service,
|
||||
usrmgrSvc *usrmgr.Service,
|
||||
authSvc *auth.Service,
|
||||
authzSvc *authz.Service,
|
||||
authCfg AuthConfig,
|
||||
connectorRegistry *connector.ConnectorRegistry,
|
||||
safeRedirect *saferedirect.SafeRedirect,
|
||||
@@ -151,14 +154,14 @@ func NewMux(
|
||||
},
|
||||
)
|
||||
|
||||
r.Post("/auth/register", SignUpHandler(usrmgrSvc, authCfg))
|
||||
r.Post("/auth/login", SignInHandler(usrmgrSvc, authCfg))
|
||||
r.Delete("/auth/logout", SignOutHandler(usrmgrSvc, authCfg))
|
||||
r.Post("/auth/invitation", InvitationConfirmationHandler(usrmgrSvc, proboSvc, authCfg))
|
||||
r.Post("/auth/forget-password", ForgetPasswordHandler(usrmgrSvc, authCfg))
|
||||
r.Post("/auth/reset-password", ResetPasswordHandler(usrmgrSvc, authCfg))
|
||||
r.Post("/auth/register", SignUpHandler(authSvc, authCfg))
|
||||
r.Post("/auth/login", SignInHandler(authSvc, authCfg))
|
||||
r.Delete("/auth/logout", SignOutHandler(authSvc, authCfg))
|
||||
r.Post("/auth/invitation", InvitationConfirmationHandler(authSvc, authzSvc, authCfg))
|
||||
r.Post("/auth/forget-password", ForgetPasswordHandler(authSvc, authCfg))
|
||||
r.Post("/auth/reset-password", ResetPasswordHandler(authSvc, authCfg))
|
||||
|
||||
r.Get("/connectors/initiate", WithSession(usrmgrSvc, authCfg, func(w http.ResponseWriter, r *http.Request) {
|
||||
r.Get("/connectors/initiate", WithSession(authSvc, authzSvc, authCfg, func(w http.ResponseWriter, r *http.Request) {
|
||||
connectorID := r.URL.Query().Get("connector_id")
|
||||
organizationID, err := gid.ParseGID(r.URL.Query().Get("organization_id"))
|
||||
if err != nil {
|
||||
@@ -175,7 +178,7 @@ func NewMux(
|
||||
http.Redirect(w, r, redirectURL, http.StatusSeeOther)
|
||||
}))
|
||||
|
||||
r.Get("/connectors/complete", WithSession(usrmgrSvc, authCfg, func(w http.ResponseWriter, r *http.Request) {
|
||||
r.Get("/connectors/complete", WithSession(authSvc, authzSvc, authCfg, func(w http.ResponseWriter, r *http.Request) {
|
||||
connectorID := r.URL.Query().Get("connector_id")
|
||||
organizationID, err := gid.ParseGID(r.URL.Query().Get("organization_id"))
|
||||
if err != nil {
|
||||
@@ -206,19 +209,20 @@ func NewMux(
|
||||
}))
|
||||
|
||||
r.Get("/", playground.Handler("GraphQL", "/api/console/v1/query"))
|
||||
r.Post("/query", graphqlHandler(logger, proboSvc, usrmgrSvc, authCfg, customDomainCname))
|
||||
r.Post("/query", graphqlHandler(logger, proboSvc, authSvc, authzSvc, authCfg, customDomainCname))
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
func graphqlHandler(logger *log.Logger, proboSvc *probo.Service, usrmgrSvc *usrmgr.Service, authCfg AuthConfig, customDomainCname string) http.HandlerFunc {
|
||||
func graphqlHandler(logger *log.Logger, proboSvc *probo.Service, authSvc *auth.Service, authzSvc *authz.Service, authCfg AuthConfig, customDomainCname string) http.HandlerFunc {
|
||||
var mb int64 = 1 << 20
|
||||
|
||||
es := schema.NewExecutableSchema(
|
||||
schema.Config{
|
||||
Resolvers: &Resolver{
|
||||
proboSvc: proboSvc,
|
||||
usrmgrSvc: usrmgrSvc,
|
||||
authSvc: authSvc,
|
||||
authzSvc: authzSvc,
|
||||
authCfg: authCfg,
|
||||
customDomainCname: customDomainCname,
|
||||
},
|
||||
@@ -259,10 +263,10 @@ func graphqlHandler(logger *log.Logger, proboSvc *probo.Service, usrmgrSvc *usrm
|
||||
},
|
||||
)
|
||||
|
||||
return WithSession(usrmgrSvc, authCfg, srv.ServeHTTP)
|
||||
return WithSession(authSvc, authzSvc, authCfg, srv.ServeHTTP)
|
||||
}
|
||||
|
||||
func WithSession(usrmgrSvc *usrmgr.Service, authCfg AuthConfig, next http.HandlerFunc) http.HandlerFunc {
|
||||
func WithSession(authSvc *auth.Service, authzSvc *authz.Service, authCfg AuthConfig, next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
|
||||
@@ -289,7 +293,7 @@ func WithSession(usrmgrSvc *usrmgr.Service, authCfg AuthConfig, next http.Handle
|
||||
},
|
||||
}
|
||||
|
||||
authResult := session.TryAuth(ctx, w, r, usrmgrSvc, sessionAuthCfg, errorHandler)
|
||||
authResult := session.TryAuth(ctx, w, r, authSvc, authzSvc, sessionAuthCfg, errorHandler)
|
||||
if authResult == nil {
|
||||
next(w, r)
|
||||
return
|
||||
@@ -302,7 +306,7 @@ func WithSession(usrmgrSvc *usrmgr.Service, authCfg AuthConfig, next http.Handle
|
||||
next(w, r.WithContext(ctx))
|
||||
|
||||
// Update session after the handler completes
|
||||
if err := usrmgrSvc.UpdateSession(ctx, authResult.Session); err != nil {
|
||||
if _, err := authSvc.UpdateSession(ctx, authResult.Session.ID); err != nil {
|
||||
panic(fmt.Errorf("failed to update session: %w", err))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1209,6 +1209,54 @@ enum SnapshotOrderField
|
||||
)
|
||||
}
|
||||
|
||||
enum MembershipOrderField
|
||||
@goModel(model: "github.com/getprobo/probo/pkg/coredata.MembershipOrderField") {
|
||||
FULL_NAME
|
||||
@goEnum(
|
||||
value: "github.com/getprobo/probo/pkg/coredata.MembershipOrderFieldFullName"
|
||||
)
|
||||
EMAIL_ADDRESS
|
||||
@goEnum(
|
||||
value: "github.com/getprobo/probo/pkg/coredata.MembershipOrderFieldEmailAddress"
|
||||
)
|
||||
ROLE
|
||||
@goEnum(
|
||||
value: "github.com/getprobo/probo/pkg/coredata.MembershipOrderFieldRole"
|
||||
)
|
||||
CREATED_AT
|
||||
@goEnum(
|
||||
value: "github.com/getprobo/probo/pkg/coredata.MembershipOrderFieldCreatedAt"
|
||||
)
|
||||
}
|
||||
|
||||
enum InvitationOrderField
|
||||
@goModel(model: "github.com/getprobo/probo/pkg/coredata.InvitationOrderField") {
|
||||
FULL_NAME
|
||||
@goEnum(
|
||||
value: "github.com/getprobo/probo/pkg/coredata.InvitationOrderFieldFullName"
|
||||
)
|
||||
EMAIL
|
||||
@goEnum(
|
||||
value: "github.com/getprobo/probo/pkg/coredata.InvitationOrderFieldEmail"
|
||||
)
|
||||
ROLE
|
||||
@goEnum(
|
||||
value: "github.com/getprobo/probo/pkg/coredata.InvitationOrderFieldRole"
|
||||
)
|
||||
CREATED_AT
|
||||
@goEnum(
|
||||
value: "github.com/getprobo/probo/pkg/coredata.InvitationOrderFieldCreatedAt"
|
||||
)
|
||||
EXPIRES_AT
|
||||
@goEnum(
|
||||
value: "github.com/getprobo/probo/pkg/coredata.InvitationOrderFieldExpiresAt"
|
||||
)
|
||||
ACCEPTED_AT
|
||||
@goEnum(
|
||||
value: "github.com/getprobo/probo/pkg/coredata.InvitationOrderFieldAcceptedAt"
|
||||
)
|
||||
}
|
||||
|
||||
# Input Types
|
||||
input UserOrder
|
||||
@goModel(
|
||||
@@ -1404,6 +1452,19 @@ input SnapshotOrder
|
||||
field: SnapshotOrderField!
|
||||
}
|
||||
|
||||
input MembershipOrder
|
||||
@goModel(
|
||||
model: "github.com/getprobo/probo/pkg/server/api/console/v1/types.MembershipOrderBy"
|
||||
) {
|
||||
direction: OrderDirection!
|
||||
field: MembershipOrderField!
|
||||
}
|
||||
|
||||
input InvitationOrder {
|
||||
direction: OrderDirection!
|
||||
field: InvitationOrderField!
|
||||
}
|
||||
|
||||
input DocumentVersionFilter {
|
||||
status: DocumentStatus
|
||||
}
|
||||
@@ -1497,13 +1558,21 @@ type Organization implements Node {
|
||||
email: String
|
||||
headquarterAddress: String
|
||||
|
||||
users(
|
||||
memberships(
|
||||
first: Int
|
||||
after: CursorKey
|
||||
last: Int
|
||||
before: CursorKey
|
||||
orderBy: UserOrder
|
||||
): UserConnection! @goField(forceResolver: true)
|
||||
orderBy: MembershipOrder
|
||||
): MembershipConnection! @goField(forceResolver: true)
|
||||
|
||||
invitations(
|
||||
first: Int
|
||||
after: CursorKey
|
||||
last: Int
|
||||
before: CursorKey
|
||||
orderBy: InvitationOrder
|
||||
): InvitationConnection! @goField(forceResolver: true)
|
||||
|
||||
connectors(
|
||||
first: Int
|
||||
@@ -1671,6 +1740,27 @@ type User implements Node {
|
||||
people(organizationId: ID!): People @goField(forceResolver: true)
|
||||
}
|
||||
|
||||
type Membership implements Node {
|
||||
id: ID!
|
||||
userID: ID!
|
||||
organizationID: ID!
|
||||
role: String!
|
||||
fullName: String!
|
||||
emailAddress: String!
|
||||
createdAt: Datetime!
|
||||
updatedAt: Datetime!
|
||||
}
|
||||
|
||||
type Invitation implements Node {
|
||||
id: ID!
|
||||
email: String!
|
||||
fullName: String!
|
||||
role: String!
|
||||
expiresAt: Datetime!
|
||||
acceptedAt: Datetime
|
||||
createdAt: Datetime!
|
||||
}
|
||||
|
||||
type Connector implements Node {
|
||||
id: ID!
|
||||
name: String!
|
||||
@@ -2305,10 +2395,22 @@ type TrustCenterReferenceEdge {
|
||||
}
|
||||
|
||||
type UserConnection {
|
||||
totalCount: Int! @goField(forceResolver: true)
|
||||
edges: [UserEdge!]!
|
||||
pageInfo: PageInfo!
|
||||
}
|
||||
|
||||
type MembershipConnection {
|
||||
totalCount: Int! @goField(forceResolver: true)
|
||||
edges: [MembershipEdge!]!
|
||||
pageInfo: PageInfo!
|
||||
}
|
||||
|
||||
type MembershipEdge {
|
||||
cursor: CursorKey!
|
||||
node: Membership!
|
||||
}
|
||||
|
||||
type UserEdge {
|
||||
cursor: CursorKey!
|
||||
node: User!
|
||||
@@ -2607,6 +2709,17 @@ type File {
|
||||
updatedAt: Datetime!
|
||||
}
|
||||
|
||||
type InvitationConnection {
|
||||
totalCount: Int! @goField(forceResolver: true)
|
||||
edges: [InvitationEdge!]!
|
||||
pageInfo: PageInfo!
|
||||
}
|
||||
|
||||
type InvitationEdge {
|
||||
cursor: CursorKey!
|
||||
node: Invitation!
|
||||
}
|
||||
|
||||
# Root Types
|
||||
type Query {
|
||||
node(id: ID!): Node!
|
||||
@@ -2667,7 +2780,8 @@ type Mutation {
|
||||
# User mutations
|
||||
confirmEmail(input: ConfirmEmailInput!): ConfirmEmailPayload!
|
||||
inviteUser(input: InviteUserInput!): InviteUserPayload!
|
||||
removeUser(input: RemoveUserInput!): RemoveUserPayload!
|
||||
deleteInvitation(input: DeleteInvitationInput!): DeleteInvitationPayload!
|
||||
removeMember(input: RemoveMemberInput!): RemoveMemberPayload!
|
||||
|
||||
# People mutations
|
||||
createPeople(input: CreatePeopleInput!): CreatePeoplePayload!
|
||||
@@ -3426,9 +3540,13 @@ input InviteUserInput {
|
||||
createPeople: Boolean!
|
||||
}
|
||||
|
||||
input RemoveUserInput {
|
||||
input DeleteInvitationInput {
|
||||
invitationId: ID!
|
||||
}
|
||||
|
||||
input RemoveMemberInput {
|
||||
organizationId: ID!
|
||||
userId: ID!
|
||||
memberId: ID!
|
||||
}
|
||||
|
||||
input CreateControlInput {
|
||||
@@ -3945,10 +4063,14 @@ type ConfirmEmailPayload {
|
||||
}
|
||||
|
||||
type InviteUserPayload {
|
||||
success: Boolean!
|
||||
invitationEdge: InvitationEdge!
|
||||
}
|
||||
|
||||
type RemoveUserPayload {
|
||||
type DeleteInvitationPayload {
|
||||
deletedInvitationId: ID!
|
||||
}
|
||||
|
||||
type RemoveMemberPayload {
|
||||
success: Boolean!
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -23,7 +23,7 @@ import (
|
||||
|
||||
"github.com/getprobo/probo/pkg/gid"
|
||||
"github.com/getprobo/probo/pkg/securecookie"
|
||||
"github.com/getprobo/probo/pkg/usrmgr"
|
||||
"github.com/getprobo/probo/pkg/auth"
|
||||
"go.gearno.de/kit/httpserver"
|
||||
)
|
||||
|
||||
@@ -46,7 +46,7 @@ type (
|
||||
}
|
||||
)
|
||||
|
||||
func SignInHandler(usrmgrSvc *usrmgr.Service, authCfg AuthConfig) http.HandlerFunc {
|
||||
func SignInHandler(authSvc *auth.Service, authCfg AuthConfig) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
var req SignInRequest
|
||||
@@ -55,9 +55,9 @@ func SignInHandler(usrmgrSvc *usrmgr.Service, authCfg AuthConfig) http.HandlerFu
|
||||
return
|
||||
}
|
||||
|
||||
user, session, err := usrmgrSvc.SignIn(r.Context(), req.Email, req.Password)
|
||||
session, user, err := authSvc.SignIn(r.Context(), req.Email, req.Password)
|
||||
if err != nil {
|
||||
var ErrInvalidCredentials *usrmgr.ErrInvalidCredentials
|
||||
var ErrInvalidCredentials *auth.ErrInvalidCredentials
|
||||
if errors.As(err, &ErrInvalidCredentials) {
|
||||
httpserver.RenderError(w, http.StatusUnauthorized, err)
|
||||
return
|
||||
|
||||
@@ -20,11 +20,11 @@ import (
|
||||
|
||||
"github.com/getprobo/probo/pkg/gid"
|
||||
"github.com/getprobo/probo/pkg/securecookie"
|
||||
"github.com/getprobo/probo/pkg/usrmgr"
|
||||
"github.com/getprobo/probo/pkg/auth"
|
||||
"go.gearno.de/kit/httpserver"
|
||||
)
|
||||
|
||||
func SignOutHandler(usrmgrSvc *usrmgr.Service, authCfg AuthConfig) http.HandlerFunc {
|
||||
func SignOutHandler(authSvc *auth.Service, authCfg AuthConfig) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
sessionID, err := securecookie.Get(r, securecookie.DefaultConfig(
|
||||
@@ -42,7 +42,7 @@ func SignOutHandler(usrmgrSvc *usrmgr.Service, authCfg AuthConfig) http.HandlerF
|
||||
return
|
||||
}
|
||||
|
||||
err = usrmgrSvc.SignOut(r.Context(), gid)
|
||||
err = authSvc.SignOut(r.Context(), gid)
|
||||
if err != nil {
|
||||
panic(fmt.Errorf("cannot sign out: %w", err))
|
||||
}
|
||||
|
||||
@@ -20,9 +20,8 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/getprobo/probo/pkg/coredata"
|
||||
"github.com/getprobo/probo/pkg/auth"
|
||||
"github.com/getprobo/probo/pkg/securecookie"
|
||||
"github.com/getprobo/probo/pkg/usrmgr"
|
||||
"go.gearno.de/kit/httpserver"
|
||||
)
|
||||
|
||||
@@ -38,7 +37,7 @@ type (
|
||||
}
|
||||
)
|
||||
|
||||
func SignUpHandler(usrmgrSvc *usrmgr.Service, authCfg AuthConfig) http.HandlerFunc {
|
||||
func SignUpHandler(authSvc *auth.Service, authCfg AuthConfig) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
var req SignUpRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
@@ -46,20 +45,20 @@ func SignUpHandler(usrmgrSvc *usrmgr.Service, authCfg AuthConfig) http.HandlerFu
|
||||
return
|
||||
}
|
||||
|
||||
user, session, err := usrmgrSvc.SignUp(
|
||||
user, session, err := authSvc.SignUp(
|
||||
r.Context(),
|
||||
req.Email,
|
||||
req.Password,
|
||||
req.FullName,
|
||||
)
|
||||
if err != nil {
|
||||
var errUserAlreadyExists *coredata.ErrUserAlreadyExists
|
||||
var errUserAlreadyExists *auth.ErrUserAlreadyExists
|
||||
if errors.As(err, &errUserAlreadyExists) {
|
||||
httpserver.RenderError(w, http.StatusBadRequest, fmt.Errorf("cannot register user: %w", err))
|
||||
return
|
||||
}
|
||||
|
||||
var errSignupDisabled *usrmgr.ErrSignupDisabled
|
||||
var errSignupDisabled *auth.ErrSignupDisabled
|
||||
if errors.As(err, &errSignupDisabled) {
|
||||
httpserver.RenderError(w, http.StatusBadRequest, fmt.Errorf("cannot register user: %w", err))
|
||||
return
|
||||
|
||||
52
pkg/server/api/console/v1/types/invitation.go
Normal file
52
pkg/server/api/console/v1/types/invitation.go
Normal file
@@ -0,0 +1,52 @@
|
||||
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package types
|
||||
|
||||
import (
|
||||
"github.com/getprobo/probo/pkg/coredata"
|
||||
"github.com/getprobo/probo/pkg/page"
|
||||
)
|
||||
|
||||
func NewInvitationConnection(p *page.Page[*coredata.Invitation, coredata.InvitationOrderField]) *InvitationConnection {
|
||||
var edges = make([]*InvitationEdge, len(p.Data))
|
||||
|
||||
for i := range edges {
|
||||
edges[i] = NewInvitationEdge(p.Data[i], p.Cursor.OrderBy.Field)
|
||||
}
|
||||
|
||||
return &InvitationConnection{
|
||||
Edges: edges,
|
||||
PageInfo: NewPageInfo(p),
|
||||
}
|
||||
}
|
||||
|
||||
func NewInvitationEdge(invitation *coredata.Invitation, orderBy coredata.InvitationOrderField) *InvitationEdge {
|
||||
return &InvitationEdge{
|
||||
Cursor: invitation.CursorKey(orderBy),
|
||||
Node: NewInvitation(invitation),
|
||||
}
|
||||
}
|
||||
|
||||
func NewInvitation(i *coredata.Invitation) *Invitation {
|
||||
return &Invitation{
|
||||
ID: i.ID,
|
||||
Email: i.Email,
|
||||
FullName: i.FullName,
|
||||
Role: i.Role,
|
||||
ExpiresAt: i.ExpiresAt,
|
||||
AcceptedAt: i.AcceptedAt,
|
||||
CreatedAt: i.CreatedAt,
|
||||
}
|
||||
}
|
||||
57
pkg/server/api/console/v1/types/membership.go
Normal file
57
pkg/server/api/console/v1/types/membership.go
Normal file
@@ -0,0 +1,57 @@
|
||||
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package types
|
||||
|
||||
import (
|
||||
"github.com/getprobo/probo/pkg/coredata"
|
||||
"github.com/getprobo/probo/pkg/page"
|
||||
)
|
||||
|
||||
type (
|
||||
MembershipOrderBy OrderBy[coredata.MembershipOrderField]
|
||||
)
|
||||
|
||||
func NewMembershipConnection(p *page.Page[*coredata.Membership, coredata.MembershipOrderField]) *MembershipConnection {
|
||||
var edges = make([]*MembershipEdge, len(p.Data))
|
||||
|
||||
for i := range edges {
|
||||
edges[i] = NewMembershipEdge(p.Data[i], p.Cursor.OrderBy.Field)
|
||||
}
|
||||
|
||||
return &MembershipConnection{
|
||||
Edges: edges,
|
||||
PageInfo: NewPageInfo(p),
|
||||
}
|
||||
}
|
||||
|
||||
func NewMembershipEdge(membership *coredata.Membership, orderBy coredata.MembershipOrderField) *MembershipEdge {
|
||||
return &MembershipEdge{
|
||||
Cursor: membership.CursorKey(orderBy),
|
||||
Node: NewMembership(membership),
|
||||
}
|
||||
}
|
||||
|
||||
func NewMembership(m *coredata.Membership) *Membership {
|
||||
return &Membership{
|
||||
ID: m.ID,
|
||||
UserID: m.UserID,
|
||||
OrganizationID: m.OrganizationID,
|
||||
Role: m.Role,
|
||||
FullName: m.FullName,
|
||||
EmailAddress: m.EmailAddress,
|
||||
CreatedAt: m.CreatedAt,
|
||||
UpdatedAt: m.UpdatedAt,
|
||||
}
|
||||
}
|
||||
@@ -807,6 +807,14 @@ type DeleteFrameworkPayload struct {
|
||||
DeletedFrameworkID gid.GID `json:"deletedFrameworkId"`
|
||||
}
|
||||
|
||||
type DeleteInvitationInput struct {
|
||||
InvitationID gid.GID `json:"invitationId"`
|
||||
}
|
||||
|
||||
type DeleteInvitationPayload struct {
|
||||
DeletedInvitationID gid.GID `json:"deletedInvitationId"`
|
||||
}
|
||||
|
||||
type DeleteMeasureInput struct {
|
||||
MeasureID gid.GID `json:"measureId"`
|
||||
}
|
||||
@@ -1191,6 +1199,35 @@ type ImportMeasurePayload struct {
|
||||
MeasureEdges []*MeasureEdge `json:"measureEdges"`
|
||||
}
|
||||
|
||||
type Invitation struct {
|
||||
ID gid.GID `json:"id"`
|
||||
Email string `json:"email"`
|
||||
FullName string `json:"fullName"`
|
||||
Role string `json:"role"`
|
||||
ExpiresAt time.Time `json:"expiresAt"`
|
||||
AcceptedAt *time.Time `json:"acceptedAt,omitempty"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
}
|
||||
|
||||
func (Invitation) IsNode() {}
|
||||
func (this Invitation) GetID() gid.GID { return this.ID }
|
||||
|
||||
type InvitationConnection struct {
|
||||
TotalCount int `json:"totalCount"`
|
||||
Edges []*InvitationEdge `json:"edges"`
|
||||
PageInfo *PageInfo `json:"pageInfo"`
|
||||
}
|
||||
|
||||
type InvitationEdge struct {
|
||||
Cursor page.CursorKey `json:"cursor"`
|
||||
Node *Invitation `json:"node"`
|
||||
}
|
||||
|
||||
type InvitationOrder struct {
|
||||
Direction page.OrderDirection `json:"direction"`
|
||||
Field coredata.InvitationOrderField `json:"field"`
|
||||
}
|
||||
|
||||
type InviteUserInput struct {
|
||||
OrganizationID gid.GID `json:"organizationId"`
|
||||
Email string `json:"email"`
|
||||
@@ -1199,7 +1236,7 @@ type InviteUserInput struct {
|
||||
}
|
||||
|
||||
type InviteUserPayload struct {
|
||||
Success bool `json:"success"`
|
||||
InvitationEdge *InvitationEdge `json:"invitationEdge"`
|
||||
}
|
||||
|
||||
type Measure struct {
|
||||
@@ -1229,6 +1266,31 @@ type MeasureFilter struct {
|
||||
State *coredata.MeasureState `json:"state,omitempty"`
|
||||
}
|
||||
|
||||
type Membership struct {
|
||||
ID gid.GID `json:"id"`
|
||||
UserID gid.GID `json:"userID"`
|
||||
OrganizationID gid.GID `json:"organizationID"`
|
||||
Role string `json:"role"`
|
||||
FullName string `json:"fullName"`
|
||||
EmailAddress string `json:"emailAddress"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
func (Membership) IsNode() {}
|
||||
func (this Membership) GetID() gid.GID { return this.ID }
|
||||
|
||||
type MembershipConnection struct {
|
||||
TotalCount int `json:"totalCount"`
|
||||
Edges []*MembershipEdge `json:"edges"`
|
||||
PageInfo *PageInfo `json:"pageInfo"`
|
||||
}
|
||||
|
||||
type MembershipEdge struct {
|
||||
Cursor page.CursorKey `json:"cursor"`
|
||||
Node *Membership `json:"node"`
|
||||
}
|
||||
|
||||
type Mutation struct {
|
||||
}
|
||||
|
||||
@@ -1301,7 +1363,8 @@ type Organization struct {
|
||||
WebsiteURL *string `json:"websiteUrl,omitempty"`
|
||||
Email *string `json:"email,omitempty"`
|
||||
HeadquarterAddress *string `json:"headquarterAddress,omitempty"`
|
||||
Users *UserConnection `json:"users"`
|
||||
Memberships *MembershipConnection `json:"memberships"`
|
||||
Invitations *InvitationConnection `json:"invitations"`
|
||||
Connectors *ConnectorConnection `json:"connectors"`
|
||||
Frameworks *FrameworkConnection `json:"frameworks"`
|
||||
Controls *ControlConnection `json:"controls"`
|
||||
@@ -1424,12 +1487,12 @@ type PublishDocumentVersionPayload struct {
|
||||
type Query struct {
|
||||
}
|
||||
|
||||
type RemoveUserInput struct {
|
||||
type RemoveMemberInput struct {
|
||||
OrganizationID gid.GID `json:"organizationId"`
|
||||
UserID gid.GID `json:"userId"`
|
||||
MemberID gid.GID `json:"memberId"`
|
||||
}
|
||||
|
||||
type RemoveUserPayload struct {
|
||||
type RemoveMemberPayload struct {
|
||||
Success bool `json:"success"`
|
||||
}
|
||||
|
||||
@@ -2064,8 +2127,9 @@ func (User) IsNode() {}
|
||||
func (this User) GetID() gid.GID { return this.ID }
|
||||
|
||||
type UserConnection struct {
|
||||
Edges []*UserEdge `json:"edges"`
|
||||
PageInfo *PageInfo `json:"pageInfo"`
|
||||
TotalCount int `json:"totalCount"`
|
||||
Edges []*UserEdge `json:"edges"`
|
||||
PageInfo *PageInfo `json:"pageInfo"`
|
||||
}
|
||||
|
||||
type UserEdge struct {
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/getprobo/probo/pkg/authz"
|
||||
"github.com/getprobo/probo/pkg/coredata"
|
||||
"github.com/getprobo/probo/pkg/gid"
|
||||
"github.com/getprobo/probo/pkg/page"
|
||||
@@ -890,6 +891,27 @@ func (r *frameworkConnectionResolver) TotalCount(ctx context.Context, obj *types
|
||||
panic(fmt.Errorf("unsupported resolver: %T", obj.Resolver))
|
||||
}
|
||||
|
||||
// TotalCount is the resolver for the totalCount field.
|
||||
func (r *invitationConnectionResolver) TotalCount(ctx context.Context, obj *types.InvitationConnection) (int, error) {
|
||||
currentUser := UserFromContext(ctx)
|
||||
if currentUser == nil {
|
||||
return 0, fmt.Errorf("no authenticated user")
|
||||
}
|
||||
|
||||
memberships, err := r.authzSvc.GetAllUserOrganizations(ctx, currentUser.ID)
|
||||
if err != nil || len(memberships) == 0 {
|
||||
return 0, fmt.Errorf("user has no organization memberships")
|
||||
}
|
||||
|
||||
orgID := memberships[0].ID
|
||||
count, err := r.authzSvc.CountOrganizationInvitations(ctx, orgID)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to count invitations: %w", err)
|
||||
}
|
||||
|
||||
return count, nil
|
||||
}
|
||||
|
||||
// Evidences is the resolver for the evidences field.
|
||||
func (r *measureResolver) Evidences(ctx context.Context, obj *types.Measure, first *int, after *page.CursorKey, last *int, before *page.CursorKey, orderBy *types.EvidenceOrderBy) (*types.EvidenceConnection, error) {
|
||||
prb := r.ProboService(ctx, obj.ID.TenantID())
|
||||
@@ -1028,6 +1050,27 @@ func (r *measureConnectionResolver) TotalCount(ctx context.Context, obj *types.M
|
||||
panic(fmt.Errorf("unsupported resolver: %T", obj.Resolver))
|
||||
}
|
||||
|
||||
// TotalCount is the resolver for the totalCount field.
|
||||
func (r *membershipConnectionResolver) TotalCount(ctx context.Context, obj *types.MembershipConnection) (int, error) {
|
||||
currentUser := UserFromContext(ctx)
|
||||
if currentUser == nil {
|
||||
return 0, fmt.Errorf("no authenticated user")
|
||||
}
|
||||
|
||||
memberships, err := r.authzSvc.GetAllUserOrganizations(ctx, currentUser.ID)
|
||||
if err != nil || len(memberships) == 0 {
|
||||
return 0, fmt.Errorf("user has no organization memberships")
|
||||
}
|
||||
|
||||
orgID := memberships[0].ID
|
||||
count, err := r.authzSvc.CountOrganizationMemberships(ctx, orgID)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to count memberships: %w", err)
|
||||
}
|
||||
|
||||
return count, nil
|
||||
}
|
||||
|
||||
// CreateOrganization is the resolver for the createOrganization field.
|
||||
func (r *mutationResolver) CreateOrganization(ctx context.Context, input types.CreateOrganizationInput) (*types.CreateOrganizationPayload, error) {
|
||||
prb := r.proboSvc.WithTenant(gid.NewTenantID())
|
||||
@@ -1042,10 +1085,11 @@ func (r *mutationResolver) CreateOrganization(ctx context.Context, input types.C
|
||||
return nil, fmt.Errorf("cannot create organization: %w", err)
|
||||
}
|
||||
|
||||
err = r.usrmgrSvc.EnrollUserInOrganization(
|
||||
err = r.authzSvc.AddUserToOrganization(
|
||||
ctx,
|
||||
UserFromContext(ctx).ID,
|
||||
organization.ID,
|
||||
string(authz.RoleMember),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot add user to organization: %w", err)
|
||||
@@ -1324,7 +1368,7 @@ func (r *mutationResolver) DeleteTrustCenterReference(ctx context.Context, input
|
||||
|
||||
// ConfirmEmail is the resolver for the confirmEmail field.
|
||||
func (r *mutationResolver) ConfirmEmail(ctx context.Context, input types.ConfirmEmailInput) (*types.ConfirmEmailPayload, error) {
|
||||
err := r.usrmgrSvc.ConfirmEmail(ctx, input.Token)
|
||||
err := r.authSvc.ConfirmEmail(ctx, input.Token)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -1337,44 +1381,70 @@ func (r *mutationResolver) ConfirmEmail(ctx context.Context, input types.Confirm
|
||||
func (r *mutationResolver) InviteUser(ctx context.Context, input types.InviteUserInput) (*types.InviteUserPayload, error) {
|
||||
user := UserFromContext(ctx)
|
||||
|
||||
organizations, err := r.usrmgrSvc.ListOrganizationsForUserID(ctx, user.ID)
|
||||
organizations, err := r.authzSvc.GetAllUserOrganizations(ctx, user.ID)
|
||||
if err != nil {
|
||||
panic(fmt.Errorf("failed to list organizations for user: %w", err))
|
||||
}
|
||||
|
||||
for _, organization := range organizations {
|
||||
if organization.ID == input.OrganizationID {
|
||||
createPeople := input.CreatePeople
|
||||
|
||||
err := r.usrmgrSvc.InviteUser(ctx, input.OrganizationID, input.FullName, input.Email, createPeople)
|
||||
invitation, err := r.authzSvc.InviteUserToOrganization(ctx, input.OrganizationID, input.Email, input.FullName, string(authz.RoleMember))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &types.InviteUserPayload{Success: true}, nil
|
||||
if input.CreatePeople {
|
||||
prb := r.ProboService(ctx, input.OrganizationID.TenantID())
|
||||
_, err := prb.Peoples.Create(ctx, probo.CreatePeopleRequest{
|
||||
OrganizationID: input.OrganizationID,
|
||||
FullName: input.FullName,
|
||||
PrimaryEmailAddress: input.Email,
|
||||
AdditionalEmailAddresses: []string{},
|
||||
Kind: coredata.PeopleKindEmployee,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create people record: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return &types.InviteUserPayload{
|
||||
InvitationEdge: types.NewInvitationEdge(invitation, coredata.InvitationOrderFieldCreatedAt),
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("organization not found")
|
||||
}
|
||||
|
||||
// RemoveUser is the resolver for the removeUser field.
|
||||
func (r *mutationResolver) RemoveUser(ctx context.Context, input types.RemoveUserInput) (*types.RemoveUserPayload, error) {
|
||||
// DeleteInvitation is the resolver for the deleteInvitation field.
|
||||
func (r *mutationResolver) DeleteInvitation(ctx context.Context, input types.DeleteInvitationInput) (*types.DeleteInvitationPayload, error) {
|
||||
err := r.authzSvc.DeleteInvitation(ctx, input.InvitationID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &types.DeleteInvitationPayload{
|
||||
DeletedInvitationID: input.InvitationID,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// RemoveMember is the resolver for the removeMember field.
|
||||
func (r *mutationResolver) RemoveMember(ctx context.Context, input types.RemoveMemberInput) (*types.RemoveMemberPayload, error) {
|
||||
user := UserFromContext(ctx)
|
||||
|
||||
organizations, err := r.usrmgrSvc.ListOrganizationsForUserID(ctx, user.ID)
|
||||
organizations, err := r.authzSvc.GetAllUserOrganizations(ctx, user.ID)
|
||||
if err != nil {
|
||||
panic(fmt.Errorf("failed to list organizations for user: %w", err))
|
||||
}
|
||||
|
||||
for _, organization := range organizations {
|
||||
if organization.ID == input.OrganizationID {
|
||||
err := r.usrmgrSvc.RemoveUser(ctx, input.OrganizationID, input.UserID)
|
||||
err := r.authzSvc.RemoveMemberFromOrganization(ctx, input.OrganizationID, input.MemberID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &types.RemoveUserPayload{Success: true}, nil
|
||||
return &types.RemoveMemberPayload{Success: true}, nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3526,14 +3596,14 @@ func (r *organizationResolver) HorizontalLogoURL(ctx context.Context, obj *types
|
||||
return prb.Organizations.GenerateHorizontalLogoURL(ctx, obj.ID, 1*time.Hour)
|
||||
}
|
||||
|
||||
// Users is the resolver for the users field.
|
||||
func (r *organizationResolver) Users(ctx context.Context, obj *types.Organization, first *int, after *page.CursorKey, last *int, before *page.CursorKey, orderBy *types.UserOrderBy) (*types.UserConnection, error) {
|
||||
pageOrderBy := page.OrderBy[coredata.UserOrderField]{
|
||||
Field: coredata.UserOrderFieldCreatedAt,
|
||||
// Memberships is the resolver for the memberships field.
|
||||
func (r *organizationResolver) Memberships(ctx context.Context, obj *types.Organization, first *int, after *page.CursorKey, last *int, before *page.CursorKey, orderBy *types.MembershipOrderBy) (*types.MembershipConnection, error) {
|
||||
pageOrderBy := page.OrderBy[coredata.MembershipOrderField]{
|
||||
Field: coredata.MembershipOrderFieldCreatedAt,
|
||||
Direction: page.OrderDirectionDesc,
|
||||
}
|
||||
if orderBy != nil {
|
||||
pageOrderBy = page.OrderBy[coredata.UserOrderField]{
|
||||
pageOrderBy = page.OrderBy[coredata.MembershipOrderField]{
|
||||
Field: orderBy.Field,
|
||||
Direction: orderBy.Direction,
|
||||
}
|
||||
@@ -3541,12 +3611,35 @@ func (r *organizationResolver) Users(ctx context.Context, obj *types.Organizatio
|
||||
|
||||
cursor := types.NewCursor(first, after, last, before, pageOrderBy)
|
||||
|
||||
page, err := r.usrmgrSvc.ListUsersForTenant(ctx, obj.ID, cursor)
|
||||
page, err := r.authzSvc.GetAllOrganizationMemberships(ctx, obj.ID, cursor)
|
||||
if err != nil {
|
||||
panic(fmt.Errorf("cannot list users: %w", err))
|
||||
panic(fmt.Errorf("cannot list memberships: %w", err))
|
||||
}
|
||||
|
||||
return types.NewUserConnection(page), nil
|
||||
return types.NewMembershipConnection(page), nil
|
||||
}
|
||||
|
||||
// Invitations is the resolver for the invitations field.
|
||||
func (r *organizationResolver) Invitations(ctx context.Context, obj *types.Organization, first *int, after *page.CursorKey, last *int, before *page.CursorKey, orderBy *types.InvitationOrder) (*types.InvitationConnection, error) {
|
||||
pageOrderBy := page.OrderBy[coredata.InvitationOrderField]{
|
||||
Field: coredata.InvitationOrderFieldCreatedAt,
|
||||
Direction: page.OrderDirectionDesc,
|
||||
}
|
||||
if orderBy != nil {
|
||||
pageOrderBy = page.OrderBy[coredata.InvitationOrderField]{
|
||||
Field: orderBy.Field,
|
||||
Direction: orderBy.Direction,
|
||||
}
|
||||
}
|
||||
|
||||
cursor := types.NewCursor(first, after, last, before, pageOrderBy)
|
||||
|
||||
page, err := r.authzSvc.GetAllOrganizationInvitations(ctx, obj.ID, cursor)
|
||||
if err != nil {
|
||||
panic(fmt.Errorf("cannot list invitations: %w", err))
|
||||
}
|
||||
|
||||
return types.NewInvitationConnection(page), nil
|
||||
}
|
||||
|
||||
// Connectors is the resolver for the connectors field.
|
||||
@@ -4866,6 +4959,27 @@ func (r *userResolver) People(ctx context.Context, obj *types.User, organization
|
||||
return types.NewPeople(people), nil
|
||||
}
|
||||
|
||||
// TotalCount is the resolver for the totalCount field.
|
||||
func (r *userConnectionResolver) TotalCount(ctx context.Context, obj *types.UserConnection) (int, error) {
|
||||
currentUser := UserFromContext(ctx)
|
||||
if currentUser == nil {
|
||||
return 0, fmt.Errorf("no authenticated user")
|
||||
}
|
||||
|
||||
memberships, err := r.authzSvc.GetAllUserOrganizations(ctx, currentUser.ID)
|
||||
if err != nil || len(memberships) == 0 {
|
||||
return 0, fmt.Errorf("user has no organization memberships")
|
||||
}
|
||||
|
||||
orgID := memberships[0].ID
|
||||
count, err := r.authzSvc.CountOrganizationMemberships(ctx, orgID)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to count memberships: %w", err)
|
||||
}
|
||||
|
||||
return count, nil
|
||||
}
|
||||
|
||||
// Organization is the resolver for the organization field.
|
||||
func (r *vendorResolver) Organization(ctx context.Context, obj *types.Vendor) (*types.Organization, error) {
|
||||
prb := r.ProboService(ctx, obj.ID.TenantID())
|
||||
@@ -5229,7 +5343,7 @@ func (r *viewerResolver) Organizations(ctx context.Context, obj *types.Viewer, f
|
||||
}
|
||||
cursor := types.NewCursor(first, after, last, before, pageOrderBy)
|
||||
|
||||
organizations, err := r.usrmgrSvc.ListOrganizationsForUserIDPaginated(ctx, user.ID, cursor)
|
||||
organizations, err := r.authzSvc.GetUserOrganizations(ctx, user.ID, cursor)
|
||||
if err != nil {
|
||||
panic(fmt.Errorf("failed to list organizations for user: %w", err))
|
||||
}
|
||||
@@ -5318,6 +5432,11 @@ func (r *Resolver) FrameworkConnection() schema.FrameworkConnectionResolver {
|
||||
return &frameworkConnectionResolver{r}
|
||||
}
|
||||
|
||||
// InvitationConnection returns schema.InvitationConnectionResolver implementation.
|
||||
func (r *Resolver) InvitationConnection() schema.InvitationConnectionResolver {
|
||||
return &invitationConnectionResolver{r}
|
||||
}
|
||||
|
||||
// Measure returns schema.MeasureResolver implementation.
|
||||
func (r *Resolver) Measure() schema.MeasureResolver { return &measureResolver{r} }
|
||||
|
||||
@@ -5326,6 +5445,11 @@ func (r *Resolver) MeasureConnection() schema.MeasureConnectionResolver {
|
||||
return &measureConnectionResolver{r}
|
||||
}
|
||||
|
||||
// MembershipConnection returns schema.MembershipConnectionResolver implementation.
|
||||
func (r *Resolver) MembershipConnection() schema.MembershipConnectionResolver {
|
||||
return &membershipConnectionResolver{r}
|
||||
}
|
||||
|
||||
// Mutation returns schema.MutationResolver implementation.
|
||||
func (r *Resolver) Mutation() schema.MutationResolver { return &mutationResolver{r} }
|
||||
|
||||
@@ -5420,6 +5544,9 @@ func (r *Resolver) TrustCenterReferenceConnection() schema.TrustCenterReferenceC
|
||||
// User returns schema.UserResolver implementation.
|
||||
func (r *Resolver) User() schema.UserResolver { return &userResolver{r} }
|
||||
|
||||
// UserConnection returns schema.UserConnectionResolver implementation.
|
||||
func (r *Resolver) UserConnection() schema.UserConnectionResolver { return &userConnectionResolver{r} }
|
||||
|
||||
// Vendor returns schema.VendorResolver implementation.
|
||||
func (r *Resolver) Vendor() schema.VendorResolver { return &vendorResolver{r} }
|
||||
|
||||
@@ -5476,8 +5603,10 @@ type evidenceConnectionResolver struct{ *Resolver }
|
||||
type fileResolver struct{ *Resolver }
|
||||
type frameworkResolver struct{ *Resolver }
|
||||
type frameworkConnectionResolver struct{ *Resolver }
|
||||
type invitationConnectionResolver struct{ *Resolver }
|
||||
type measureResolver struct{ *Resolver }
|
||||
type measureConnectionResolver struct{ *Resolver }
|
||||
type membershipConnectionResolver struct{ *Resolver }
|
||||
type mutationResolver struct{ *Resolver }
|
||||
type nonconformityResolver struct{ *Resolver }
|
||||
type nonconformityConnectionResolver struct{ *Resolver }
|
||||
@@ -5502,6 +5631,7 @@ type trustCenterDocumentAccessConnectionResolver struct{ *Resolver }
|
||||
type trustCenterReferenceResolver struct{ *Resolver }
|
||||
type trustCenterReferenceConnectionResolver struct{ *Resolver }
|
||||
type userResolver struct{ *Resolver }
|
||||
type userConnectionResolver struct{ *Resolver }
|
||||
type vendorResolver struct{ *Resolver }
|
||||
type vendorBusinessAssociateAgreementResolver struct{ *Resolver }
|
||||
type vendorComplianceReportResolver struct{ *Resolver }
|
||||
|
||||
@@ -26,17 +26,18 @@ import (
|
||||
"github.com/99designs/gqlgen/graphql/handler"
|
||||
"github.com/99designs/gqlgen/graphql/handler/extension"
|
||||
"github.com/99designs/gqlgen/graphql/handler/transport"
|
||||
"github.com/getprobo/probo/pkg/auth"
|
||||
"github.com/getprobo/probo/pkg/authz"
|
||||
"github.com/getprobo/probo/pkg/coredata"
|
||||
"github.com/getprobo/probo/pkg/gid"
|
||||
"github.com/getprobo/probo/pkg/probo"
|
||||
console_v1 "github.com/getprobo/probo/pkg/server/api/console/v1"
|
||||
"github.com/getprobo/probo/pkg/server/api/trust/v1/auth"
|
||||
"github.com/getprobo/probo/pkg/server/api/trust/v1/schema"
|
||||
"github.com/getprobo/probo/pkg/server/api/trust/v1/trustauth"
|
||||
gqlutils "github.com/getprobo/probo/pkg/server/graphql"
|
||||
"github.com/getprobo/probo/pkg/server/session"
|
||||
"github.com/getprobo/probo/pkg/statelesstoken"
|
||||
"github.com/getprobo/probo/pkg/trust"
|
||||
"github.com/getprobo/probo/pkg/usrmgr"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"go.gearno.de/kit/log"
|
||||
)
|
||||
@@ -79,31 +80,32 @@ func UserFromContext(ctx context.Context) *coredata.User {
|
||||
return user
|
||||
}
|
||||
|
||||
func TokenAccessFromContext(ctx context.Context) *auth.TokenAccessData {
|
||||
tokenAccess, _ := ctx.Value(tokenAccessContextKey).(*auth.TokenAccessData)
|
||||
func TokenAccessFromContext(ctx context.Context) *trustauth.TokenAccessData {
|
||||
tokenAccess, _ := ctx.Value(tokenAccessContextKey).(*trustauth.TokenAccessData)
|
||||
return tokenAccess
|
||||
}
|
||||
|
||||
// UserFromContext implements auth.ContextAccessor interface
|
||||
// UserFromContext implements trustauth.ContextAccessor interface
|
||||
func (r *Resolver) UserFromContext(ctx context.Context) *coredata.User {
|
||||
return UserFromContext(ctx)
|
||||
}
|
||||
|
||||
// TokenAccessFromContext implements auth.ContextAccessor interface
|
||||
func (r *Resolver) TokenAccessFromContext(ctx context.Context) *auth.TokenAccessData {
|
||||
// TokenAccessFromContext implements trustauth.ContextAccessor interface
|
||||
func (r *Resolver) TokenAccessFromContext(ctx context.Context) *trustauth.TokenAccessData {
|
||||
return TokenAccessFromContext(ctx)
|
||||
}
|
||||
|
||||
func NewMux(
|
||||
logger *log.Logger,
|
||||
usrmgrSvc *usrmgr.Service,
|
||||
authSvc *auth.Service,
|
||||
authzSvc *authz.Service,
|
||||
trustSvc *trust.Service,
|
||||
authCfg console_v1.AuthConfig,
|
||||
trustAuthCfg TrustAuthConfig,
|
||||
) *chi.Mux {
|
||||
r := chi.NewMux()
|
||||
|
||||
r.Handle("/graphql", graphqlHandler(logger, usrmgrSvc, trustSvc, authCfg, trustAuthCfg))
|
||||
r.Handle("/graphql", graphqlHandler(logger, authSvc, authzSvc, trustSvc, authCfg, trustAuthCfg))
|
||||
|
||||
r.Post("/auth/authenticate", authTokenHandler(trustSvc, trustAuthCfg))
|
||||
r.Delete("/auth/logout", trustCenterLogoutHandler(authCfg, trustAuthCfg))
|
||||
@@ -111,7 +113,7 @@ func NewMux(
|
||||
return r
|
||||
}
|
||||
|
||||
func graphqlHandler(logger *log.Logger, usrmgrSvc *usrmgr.Service, trustSvc *trust.Service, authCfg console_v1.AuthConfig, trustAuthCfg TrustAuthConfig) http.HandlerFunc {
|
||||
func graphqlHandler(logger *log.Logger, authSvc *auth.Service, authzSvc *authz.Service, trustSvc *trust.Service, authCfg console_v1.AuthConfig, trustAuthCfg TrustAuthConfig) http.HandlerFunc {
|
||||
resolver := &Resolver{
|
||||
trustCenterSvc: trustSvc,
|
||||
authCfg: authCfg,
|
||||
@@ -122,7 +124,7 @@ func graphqlHandler(logger *log.Logger, usrmgrSvc *usrmgr.Service, trustSvc *tru
|
||||
Resolvers: resolver,
|
||||
}
|
||||
|
||||
c.Directives.MustBeAuthenticated = auth.MustBeAuthenticatedDirective(resolver)
|
||||
c.Directives.MustBeAuthenticated = trustauth.MustBeAuthenticatedDirective(resolver)
|
||||
|
||||
es := schema.NewExecutableSchema(c)
|
||||
|
||||
@@ -137,7 +139,7 @@ func graphqlHandler(logger *log.Logger, usrmgrSvc *usrmgr.Service, trustSvc *tru
|
||||
|
||||
srv.SetRecoverFunc(gqlutils.RecoverFunc)
|
||||
|
||||
return WithSession(usrmgrSvc, trustSvc, authCfg, trustAuthCfg, srv.ServeHTTP)
|
||||
return WithSession(authSvc, authzSvc, trustSvc, authCfg, trustAuthCfg, srv.ServeHTTP)
|
||||
}
|
||||
|
||||
func (r *Resolver) RootTrustService(ctx context.Context) *trust.TenantService {
|
||||
@@ -149,14 +151,14 @@ func (r *Resolver) PublicTrustService(ctx context.Context, tenantID gid.TenantID
|
||||
}
|
||||
|
||||
func (r *Resolver) PrivateTrustService(ctx context.Context, tenantID gid.TenantID) (*trust.TenantService, error) {
|
||||
if err := auth.ValidateTenantAccess(ctx, r, userTenantContextKey, tenantID); err != nil {
|
||||
if err := trustauth.ValidateTenantAccess(ctx, r, userTenantContextKey, tenantID); err != nil {
|
||||
return nil, fmt.Errorf("cannot access trust center: %w", err)
|
||||
}
|
||||
|
||||
return r.trustCenterSvc.WithTenant(tenantID), nil
|
||||
}
|
||||
|
||||
func WithSession(usrmgrSvc *usrmgr.Service, trustSvc *trust.Service, authCfg console_v1.AuthConfig, trustAuthCfg TrustAuthConfig, next http.HandlerFunc) http.HandlerFunc {
|
||||
func WithSession(authSvc *auth.Service, authzSvc *authz.Service, trustSvc *trust.Service, authCfg console_v1.AuthConfig, trustAuthCfg TrustAuthConfig, next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
|
||||
@@ -168,9 +170,9 @@ func WithSession(usrmgrSvc *usrmgr.Service, trustSvc *trust.Service, authCfg con
|
||||
return
|
||||
}
|
||||
|
||||
if authCtx := trySessionAuth(ctx, w, r, usrmgrSvc, authCfg); authCtx != nil {
|
||||
if authCtx := trySessionAuth(ctx, w, r, authSvc, authzSvc, authCfg); authCtx != nil {
|
||||
next(w, r.WithContext(authCtx))
|
||||
updateSessionIfNeeded(authCtx, usrmgrSvc)
|
||||
updateSessionIfNeeded(authCtx, authSvc)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -178,7 +180,7 @@ func WithSession(usrmgrSvc *usrmgr.Service, trustSvc *trust.Service, authCfg con
|
||||
}
|
||||
}
|
||||
|
||||
func trySessionAuth(ctx context.Context, w http.ResponseWriter, r *http.Request, usrmgrSvc *usrmgr.Service, authCfg console_v1.AuthConfig) context.Context {
|
||||
func trySessionAuth(ctx context.Context, w http.ResponseWriter, r *http.Request, authSvc *auth.Service, authzSvc *authz.Service, authCfg console_v1.AuthConfig) context.Context {
|
||||
sessionAuthCfg := session.AuthConfig{
|
||||
CookieName: authCfg.CookieName,
|
||||
CookieSecret: authCfg.CookieSecret,
|
||||
@@ -199,7 +201,7 @@ func trySessionAuth(ctx context.Context, w http.ResponseWriter, r *http.Request,
|
||||
},
|
||||
}
|
||||
|
||||
authResult := session.TryAuth(ctx, w, r, usrmgrSvc, sessionAuthCfg, errorHandler)
|
||||
authResult := session.TryAuth(ctx, w, r, authSvc, authzSvc, sessionAuthCfg, errorHandler)
|
||||
if authResult == nil {
|
||||
return nil
|
||||
}
|
||||
@@ -235,7 +237,7 @@ func tryTokenAuth(ctx context.Context, w http.ResponseWriter, r *http.Request, t
|
||||
return nil
|
||||
}
|
||||
|
||||
tokenAccess := &auth.TokenAccessData{
|
||||
tokenAccess := &trustauth.TokenAccessData{
|
||||
TrustCenterID: basicPayload.Data.TrustCenterID,
|
||||
Email: basicPayload.Data.Email,
|
||||
TenantID: tenantID,
|
||||
@@ -258,10 +260,10 @@ func clearTokenCookie(w http.ResponseWriter, trustAuthCfg TrustAuthConfig) {
|
||||
})
|
||||
}
|
||||
|
||||
func updateSessionIfNeeded(ctx context.Context, usrmgrSvc *usrmgr.Service) {
|
||||
func updateSessionIfNeeded(ctx context.Context, authSvc *auth.Service) {
|
||||
session := SessionFromContext(ctx)
|
||||
if session != nil {
|
||||
if err := usrmgrSvc.UpdateSession(ctx, session); err != nil {
|
||||
if _, err := authSvc.UpdateSession(ctx, session.ID); err != nil {
|
||||
panic(fmt.Errorf("failed to update session: %w", err))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package auth
|
||||
package trustauth
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -21,6 +21,8 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/getprobo/probo/pkg/agents"
|
||||
"github.com/getprobo/probo/pkg/auth"
|
||||
"github.com/getprobo/probo/pkg/authz"
|
||||
"github.com/getprobo/probo/pkg/connector"
|
||||
"github.com/getprobo/probo/pkg/gid"
|
||||
"github.com/getprobo/probo/pkg/probo"
|
||||
@@ -30,7 +32,6 @@ import (
|
||||
"github.com/getprobo/probo/pkg/server/trust"
|
||||
"github.com/getprobo/probo/pkg/server/web"
|
||||
trust_pkg "github.com/getprobo/probo/pkg/trust"
|
||||
"github.com/getprobo/probo/pkg/usrmgr"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"go.gearno.de/kit/log"
|
||||
)
|
||||
@@ -40,9 +41,10 @@ type Config struct {
|
||||
AllowedOrigins []string
|
||||
ExtraHeaderFields map[string]string
|
||||
Probo *probo.Service
|
||||
Usrmgr *usrmgr.Service
|
||||
Auth *auth.Service
|
||||
Authz *authz.Service
|
||||
Trust *trust_pkg.Service
|
||||
Auth api.ConsoleAuthConfig
|
||||
ConsoleAuth api.ConsoleAuthConfig
|
||||
TrustAuth api.TrustAuthConfig
|
||||
ConnectorRegistry *connector.ConnectorRegistry
|
||||
Agent *agents.Agent
|
||||
@@ -68,9 +70,10 @@ func NewServer(cfg Config) (*Server, error) {
|
||||
apiCfg := api.Config{
|
||||
AllowedOrigins: cfg.AllowedOrigins,
|
||||
Probo: cfg.Probo,
|
||||
Usrmgr: cfg.Usrmgr,
|
||||
Trust: cfg.Trust,
|
||||
Auth: cfg.Auth,
|
||||
Authz: cfg.Authz,
|
||||
Trust: cfg.Trust,
|
||||
ConsoleAuth: cfg.ConsoleAuth,
|
||||
TrustAuth: cfg.TrustAuth,
|
||||
ConnectorRegistry: cfg.ConnectorRegistry,
|
||||
SafeRedirect: cfg.SafeRedirect,
|
||||
|
||||
@@ -19,10 +19,11 @@ import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/getprobo/probo/pkg/authz"
|
||||
"github.com/getprobo/probo/pkg/coredata"
|
||||
"github.com/getprobo/probo/pkg/gid"
|
||||
"github.com/getprobo/probo/pkg/securecookie"
|
||||
"github.com/getprobo/probo/pkg/usrmgr"
|
||||
"github.com/getprobo/probo/pkg/auth"
|
||||
)
|
||||
|
||||
type AuthConfig struct {
|
||||
@@ -48,7 +49,8 @@ func TryAuth(
|
||||
ctx context.Context,
|
||||
w http.ResponseWriter,
|
||||
r *http.Request,
|
||||
usrmgrSvc *usrmgr.Service,
|
||||
authSvc *auth.Service,
|
||||
authzSvc *authz.Service,
|
||||
authCfg AuthConfig,
|
||||
errorHandler ErrorHandler,
|
||||
) *AuthResult {
|
||||
@@ -71,7 +73,7 @@ func TryAuth(
|
||||
return nil
|
||||
}
|
||||
|
||||
session, err := usrmgrSvc.GetSession(ctx, sessionID)
|
||||
session, err := authSvc.GetSession(ctx, sessionID)
|
||||
if err != nil {
|
||||
if errorHandler.OnSessionError != nil {
|
||||
errorHandler.OnSessionError(w, authCfg)
|
||||
@@ -79,7 +81,7 @@ func TryAuth(
|
||||
return nil
|
||||
}
|
||||
|
||||
user, err := usrmgrSvc.GetUserBySession(ctx, sessionID)
|
||||
user, err := authSvc.GetUserBySession(ctx, sessionID)
|
||||
if err != nil {
|
||||
if errorHandler.OnUserError != nil {
|
||||
errorHandler.OnUserError(w, authCfg)
|
||||
@@ -87,7 +89,7 @@ func TryAuth(
|
||||
return nil
|
||||
}
|
||||
|
||||
tenantIDs, err := usrmgrSvc.ListTenantsForUserID(ctx, user.ID)
|
||||
organizations, err := authzSvc.GetAllUserOrganizations(ctx, user.ID)
|
||||
if err != nil {
|
||||
if errorHandler.OnTenantError != nil {
|
||||
errorHandler.OnTenantError(err)
|
||||
@@ -95,6 +97,11 @@ func TryAuth(
|
||||
return nil
|
||||
}
|
||||
|
||||
tenantIDs := make([]gid.TenantID, len(organizations))
|
||||
for i, org := range organizations {
|
||||
tenantIDs[i] = org.ID.TenantID()
|
||||
}
|
||||
|
||||
return &AuthResult{
|
||||
Session: session,
|
||||
User: user,
|
||||
|
||||
@@ -16,13 +16,13 @@ package trust
|
||||
|
||||
import (
|
||||
"github.com/aws/aws-sdk-go-v2/service/s3"
|
||||
"github.com/getprobo/probo/pkg/auth"
|
||||
"github.com/getprobo/probo/pkg/coredata"
|
||||
"github.com/getprobo/probo/pkg/crypto/cipher"
|
||||
"github.com/getprobo/probo/pkg/filemanager"
|
||||
"github.com/getprobo/probo/pkg/gid"
|
||||
"github.com/getprobo/probo/pkg/html2pdf"
|
||||
"github.com/getprobo/probo/pkg/probo"
|
||||
"github.com/getprobo/probo/pkg/usrmgr"
|
||||
"go.gearno.de/kit/pg"
|
||||
)
|
||||
|
||||
@@ -34,7 +34,7 @@ type (
|
||||
proboSvc *probo.Service
|
||||
encryptionKey cipher.EncryptionKey
|
||||
tokenSecret string
|
||||
usrmgr *usrmgr.Service
|
||||
auth *auth.Service
|
||||
html2pdfConverter *html2pdf.Converter
|
||||
fileManager *filemanager.Service
|
||||
}
|
||||
@@ -47,7 +47,7 @@ type (
|
||||
proboSvc *probo.Service
|
||||
encryptionKey cipher.EncryptionKey
|
||||
tokenSecret string
|
||||
usrmgr *usrmgr.Service
|
||||
auth *auth.Service
|
||||
html2pdfConverter *html2pdf.Converter
|
||||
fileManager *filemanager.Service
|
||||
TrustCenters *TrustCenterService
|
||||
@@ -68,7 +68,7 @@ func NewService(
|
||||
bucket string,
|
||||
encryptionKey cipher.EncryptionKey,
|
||||
tokenSecret string,
|
||||
usrmgr *usrmgr.Service,
|
||||
auth *auth.Service,
|
||||
html2pdfConverter *html2pdf.Converter,
|
||||
fileManagerService *filemanager.Service,
|
||||
) *Service {
|
||||
@@ -78,7 +78,7 @@ func NewService(
|
||||
bucket: bucket,
|
||||
encryptionKey: encryptionKey,
|
||||
tokenSecret: tokenSecret,
|
||||
usrmgr: usrmgr,
|
||||
auth: auth,
|
||||
html2pdfConverter: html2pdfConverter,
|
||||
fileManager: fileManagerService,
|
||||
}
|
||||
@@ -93,7 +93,7 @@ func (s *Service) WithTenant(tenantID gid.TenantID) *TenantService {
|
||||
proboSvc: s.proboSvc,
|
||||
encryptionKey: s.encryptionKey,
|
||||
tokenSecret: s.tokenSecret,
|
||||
usrmgr: s.usrmgr,
|
||||
auth: s.auth,
|
||||
html2pdfConverter: s.html2pdfConverter,
|
||||
fileManager: s.fileManager,
|
||||
}
|
||||
@@ -103,7 +103,7 @@ func (s *Service) WithTenant(tenantID gid.TenantID) *TenantService {
|
||||
tenantService.Audits = &AuditService{svc: tenantService}
|
||||
tenantService.Vendors = &VendorService{svc: tenantService}
|
||||
tenantService.Frameworks = &FrameworkService{svc: tenantService}
|
||||
tenantService.TrustCenterAccesses = &TrustCenterAccessService{svc: tenantService, usrmgr: s.usrmgr}
|
||||
tenantService.TrustCenterAccesses = &TrustCenterAccessService{svc: tenantService, auth: s.auth}
|
||||
tenantService.TrustCenterReferences = &TrustCenterReferenceService{svc: tenantService}
|
||||
tenantService.Reports = &ReportService{svc: tenantService}
|
||||
tenantService.Organizations = &OrganizationService{svc: tenantService}
|
||||
|
||||
@@ -24,14 +24,14 @@ import (
|
||||
|
||||
"github.com/getprobo/probo/pkg/coredata"
|
||||
"github.com/getprobo/probo/pkg/gid"
|
||||
"github.com/getprobo/probo/pkg/usrmgr"
|
||||
"github.com/getprobo/probo/pkg/auth"
|
||||
"go.gearno.de/kit/pg"
|
||||
)
|
||||
|
||||
type (
|
||||
TrustCenterAccessService struct {
|
||||
svc *TenantService
|
||||
usrmgr *usrmgr.Service
|
||||
auth *auth.Service
|
||||
}
|
||||
|
||||
RequestTrustCenterAccessRequest struct {
|
||||
|
||||
@@ -1,953 +0,0 @@
|
||||
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package usrmgr
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/mail"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"github.com/getprobo/probo/pkg/coredata"
|
||||
"github.com/getprobo/probo/pkg/crypto/passwdhash"
|
||||
"github.com/getprobo/probo/pkg/gid"
|
||||
"github.com/getprobo/probo/pkg/page"
|
||||
"github.com/getprobo/probo/pkg/statelesstoken"
|
||||
"go.gearno.de/kit/pg"
|
||||
)
|
||||
|
||||
type (
|
||||
Service struct {
|
||||
pg *pg.Client
|
||||
hp *passwdhash.Profile
|
||||
hostname string
|
||||
tokenSecret string
|
||||
disableSignup bool
|
||||
invitationTokenValidity time.Duration
|
||||
}
|
||||
|
||||
ErrInvalidCredentials struct {
|
||||
message string
|
||||
}
|
||||
|
||||
ErrInvalidEmail struct {
|
||||
email string
|
||||
}
|
||||
|
||||
ErrInvalidPassword struct {
|
||||
minLength int
|
||||
maxLength int
|
||||
}
|
||||
|
||||
ErrInvalidFullName struct {
|
||||
fullName string
|
||||
}
|
||||
|
||||
ErrUserAlreadyExists struct {
|
||||
message string
|
||||
}
|
||||
|
||||
ErrSessionNotFound struct {
|
||||
message string
|
||||
}
|
||||
|
||||
ErrSessionExpired struct {
|
||||
message string
|
||||
}
|
||||
|
||||
ErrInvalidTokenType struct {
|
||||
message string
|
||||
}
|
||||
|
||||
ErrSignupDisabled struct{}
|
||||
|
||||
EmailConfirmationData struct {
|
||||
UserID gid.GID `json:"uid"`
|
||||
Email string `json:"email"`
|
||||
}
|
||||
|
||||
InvitationData struct {
|
||||
OrganizationID gid.GID `json:"organization_id"`
|
||||
Email string `json:"email"`
|
||||
FullName string `json:"full_name"`
|
||||
CreatePeople bool `json:"create_people"`
|
||||
}
|
||||
|
||||
PasswordResetData struct {
|
||||
Email string `json:"email"`
|
||||
}
|
||||
)
|
||||
|
||||
// Token types
|
||||
const (
|
||||
TokenTypeEmailConfirmation = "email_confirmation"
|
||||
TokenTypeOrganizationInvitation = "organization_invitation"
|
||||
TokenTypePasswordReset = "password_reset"
|
||||
)
|
||||
|
||||
var (
|
||||
signupEmailSubject = "Confirm your email address"
|
||||
signupEmailTemplate = `
|
||||
Thanks joining Probo!
|
||||
Please confirm your email address by clicking the link below[1]
|
||||
|
||||
[1] %s
|
||||
`
|
||||
|
||||
invitationEmailSubject = "Join Probo"
|
||||
invitationEmailTemplate = `
|
||||
You have been invited to join Probo!
|
||||
Please click the link below to sign up[1]
|
||||
|
||||
[1] %s
|
||||
`
|
||||
|
||||
passwordResetEmailSubject = "Reset your password"
|
||||
passwordResetEmailTemplate = `
|
||||
You have requested a password reset for your Probo account.
|
||||
Please click the link below to reset your password[1]
|
||||
|
||||
If you did not request this password reset, please ignore this email.
|
||||
|
||||
[1] %s
|
||||
`
|
||||
)
|
||||
|
||||
func (e ErrInvalidCredentials) Error() string {
|
||||
return e.message
|
||||
}
|
||||
|
||||
func (e ErrUserAlreadyExists) Error() string {
|
||||
return e.message
|
||||
}
|
||||
|
||||
func (e ErrSessionNotFound) Error() string {
|
||||
return e.message
|
||||
}
|
||||
|
||||
func (e ErrSessionExpired) Error() string {
|
||||
return e.message
|
||||
}
|
||||
|
||||
func (e ErrInvalidEmail) Error() string {
|
||||
return fmt.Sprintf("invalid email: %s", e.email)
|
||||
}
|
||||
|
||||
func (e ErrInvalidPassword) Error() string {
|
||||
return fmt.Sprintf("invalid password: the length must be between %d and %d characters", e.minLength, e.maxLength)
|
||||
}
|
||||
|
||||
func (e ErrInvalidFullName) Error() string {
|
||||
return fmt.Sprintf("invalid full name: %s", e.fullName)
|
||||
}
|
||||
|
||||
func (e ErrInvalidTokenType) Error() string {
|
||||
return e.message
|
||||
}
|
||||
|
||||
func (e ErrSignupDisabled) Error() string {
|
||||
return "signup is disabled, contact the owner of the Probo instance"
|
||||
}
|
||||
|
||||
func NewService(
|
||||
ctx context.Context,
|
||||
pgClient *pg.Client,
|
||||
hp *passwdhash.Profile,
|
||||
tokenSecret string,
|
||||
hostname string,
|
||||
disableSignup bool,
|
||||
invitationTokenValidity time.Duration,
|
||||
) (*Service, error) {
|
||||
return &Service{
|
||||
pg: pgClient,
|
||||
hp: hp,
|
||||
hostname: hostname,
|
||||
tokenSecret: tokenSecret,
|
||||
disableSignup: disableSignup,
|
||||
invitationTokenValidity: invitationTokenValidity,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s Service) ForgetPassword(
|
||||
ctx context.Context,
|
||||
email string,
|
||||
) error {
|
||||
// Always generate a new token to avoid timing attacks and leaking information
|
||||
// about existing emails
|
||||
passwordResetToken, err := statelesstoken.NewToken(
|
||||
s.tokenSecret,
|
||||
TokenTypePasswordReset,
|
||||
1*time.Hour,
|
||||
PasswordResetData{Email: email},
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot generate password reset token: %w", err)
|
||||
}
|
||||
|
||||
resetPasswordUrl := url.URL{
|
||||
Scheme: "https",
|
||||
Host: s.hostname,
|
||||
Path: "/auth/reset-password",
|
||||
RawQuery: url.Values{
|
||||
"token": []string{passwordResetToken},
|
||||
}.Encode(),
|
||||
}
|
||||
|
||||
user := &coredata.User{}
|
||||
|
||||
err = s.pg.WithTx(
|
||||
ctx,
|
||||
func(tx pg.Conn) error {
|
||||
if err := user.LoadByEmail(ctx, tx, email); err != nil {
|
||||
var errUserNotFound *coredata.ErrUserNotFound
|
||||
|
||||
if errors.As(err, &errUserNotFound) {
|
||||
// We don't want to leak information about existing emails
|
||||
// Return success even if the email doesn't exist
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("cannot load user by %q email: %w", email, err)
|
||||
}
|
||||
|
||||
resetPasswordEmail := coredata.NewEmail(
|
||||
user.FullName,
|
||||
user.EmailAddress,
|
||||
passwordResetEmailSubject,
|
||||
fmt.Sprintf(passwordResetEmailTemplate, resetPasswordUrl.String()),
|
||||
)
|
||||
|
||||
if err := resetPasswordEmail.Insert(ctx, tx); err != nil {
|
||||
return fmt.Errorf("cannot insert email: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s Service) SignUp(
|
||||
ctx context.Context,
|
||||
email, password, fullName string,
|
||||
) (*coredata.User, *coredata.Session, error) {
|
||||
if s.disableSignup {
|
||||
return nil, nil, &ErrSignupDisabled{}
|
||||
}
|
||||
|
||||
if _, err := mail.ParseAddress(email); err != nil {
|
||||
return nil, nil, &ErrInvalidEmail{email}
|
||||
}
|
||||
|
||||
if len(password) < 8 || len(password) > 128 {
|
||||
return nil, nil, &ErrInvalidPassword{minLength: 8, maxLength: 128}
|
||||
}
|
||||
|
||||
if fullName == "" {
|
||||
return nil, nil, &ErrInvalidFullName{fullName}
|
||||
}
|
||||
|
||||
hashedPassword, err := s.hp.HashPassword([]byte(password))
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("cannot hash password: %w", err)
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
user := &coredata.User{
|
||||
ID: gid.New(gid.NilTenant, coredata.UserEntityType),
|
||||
EmailAddress: email,
|
||||
HashedPassword: hashedPassword,
|
||||
FullName: fullName,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
|
||||
session := &coredata.Session{
|
||||
ID: gid.New(gid.NilTenant, coredata.SessionEntityType),
|
||||
UserID: user.ID,
|
||||
ExpiredAt: now.Add(24 * time.Hour),
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
|
||||
confirmationToken, err := statelesstoken.NewToken(
|
||||
s.tokenSecret,
|
||||
TokenTypeEmailConfirmation,
|
||||
1*time.Hour,
|
||||
EmailConfirmationData{UserID: user.ID, Email: user.EmailAddress},
|
||||
)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("cannot generate confirmation token: %w", err)
|
||||
}
|
||||
|
||||
confirmationEmailUrl := url.URL{
|
||||
Scheme: "https",
|
||||
Host: s.hostname,
|
||||
Path: "/auth/confirm-email",
|
||||
RawQuery: url.Values{
|
||||
"token": []string{confirmationToken},
|
||||
}.Encode(),
|
||||
}
|
||||
|
||||
confirmationEmail := coredata.NewEmail(
|
||||
user.FullName,
|
||||
user.EmailAddress,
|
||||
signupEmailSubject,
|
||||
fmt.Sprintf(signupEmailTemplate, confirmationEmailUrl.String()),
|
||||
)
|
||||
|
||||
err = s.pg.WithTx(
|
||||
ctx,
|
||||
func(tx pg.Conn) error {
|
||||
if err := user.Insert(ctx, tx); err != nil {
|
||||
return fmt.Errorf("cannot insert user: %w", err)
|
||||
}
|
||||
|
||||
if err := session.Insert(ctx, tx); err != nil {
|
||||
return fmt.Errorf("cannot insert session: %w", err)
|
||||
}
|
||||
|
||||
if err := confirmationEmail.Insert(ctx, tx); err != nil {
|
||||
return fmt.Errorf("cannot insert email: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
return user, session, nil
|
||||
}
|
||||
|
||||
func (s Service) SignIn(
|
||||
ctx context.Context,
|
||||
email, password string,
|
||||
) (*coredata.User, *coredata.Session, error) {
|
||||
now := time.Now()
|
||||
user := &coredata.User{}
|
||||
session := &coredata.Session{
|
||||
ID: gid.New(gid.NilTenant, coredata.SessionEntityType),
|
||||
UserID: gid.Nil,
|
||||
ExpiredAt: now.Add(24 * time.Hour),
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
|
||||
if len(password) < 8 || len(password) > 128 {
|
||||
return nil, nil, &ErrInvalidPassword{minLength: 8, maxLength: 128}
|
||||
}
|
||||
|
||||
err := s.pg.WithTx(
|
||||
ctx,
|
||||
func(tx pg.Conn) error {
|
||||
if err := user.LoadByEmail(ctx, tx, email); err != nil {
|
||||
_, _ = s.hp.ComparePasswordAndHash([]byte("this-compare-should-never-succeed"), []byte("it-just-to-prevent-timing-attack"))
|
||||
|
||||
var errUserNotFound *coredata.ErrUserNotFound
|
||||
|
||||
if errors.As(err, &errUserNotFound) {
|
||||
return &ErrInvalidCredentials{message: "invalid email or password"}
|
||||
}
|
||||
|
||||
return fmt.Errorf("cannot load user by email: %w", err)
|
||||
}
|
||||
|
||||
ok, err := s.hp.ComparePasswordAndHash([]byte(password), user.HashedPassword)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot compare password: %w", err)
|
||||
}
|
||||
|
||||
if !ok {
|
||||
return &ErrInvalidCredentials{message: "invalid email or password"}
|
||||
}
|
||||
|
||||
session.UserID = user.ID
|
||||
|
||||
if err := session.Insert(ctx, tx); err != nil {
|
||||
return fmt.Errorf("cannot insert session: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
return user, session, nil
|
||||
}
|
||||
|
||||
func (s Service) SignOut(
|
||||
ctx context.Context,
|
||||
sessionID gid.GID,
|
||||
) error {
|
||||
return s.pg.WithConn(
|
||||
ctx,
|
||||
func(tx pg.Conn) error {
|
||||
err := coredata.DeleteSession(ctx, tx, sessionID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot delete session: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
func (s Service) GetSession(
|
||||
ctx context.Context,
|
||||
sessionID gid.GID,
|
||||
) (*coredata.Session, error) {
|
||||
session := &coredata.Session{}
|
||||
|
||||
err := s.pg.WithTx(
|
||||
ctx,
|
||||
func(tx pg.Conn) error {
|
||||
if err := session.LoadByID(ctx, tx, sessionID); err != nil {
|
||||
return &ErrSessionNotFound{message: "session not found"}
|
||||
}
|
||||
|
||||
if time.Now().After(session.ExpiredAt) {
|
||||
if err := coredata.DeleteSession(ctx, tx, sessionID); err != nil {
|
||||
return fmt.Errorf("cannot delete expired session: %w", err)
|
||||
}
|
||||
return &ErrSessionExpired{message: "session expired"}
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return session, nil
|
||||
}
|
||||
|
||||
func (s Service) GetUserByID(
|
||||
ctx context.Context,
|
||||
userID gid.GID,
|
||||
) (*coredata.User, error) {
|
||||
user := &coredata.User{}
|
||||
|
||||
err := s.pg.WithTx(
|
||||
ctx,
|
||||
func(tx pg.Conn) error {
|
||||
if err := user.LoadByID(ctx, tx, userID); err != nil {
|
||||
return fmt.Errorf("user not found: %w", err)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func (s Service) GetUserBySession(
|
||||
ctx context.Context,
|
||||
sessionID gid.GID,
|
||||
) (*coredata.User, error) {
|
||||
session, err := s.GetSession(ctx, sessionID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return s.GetUserByID(ctx, session.UserID)
|
||||
}
|
||||
|
||||
func (s Service) ListOrganizationsForUserID(
|
||||
ctx context.Context,
|
||||
userID gid.GID,
|
||||
) (coredata.Organizations, error) {
|
||||
|
||||
uos := coredata.UserOrganizations{}
|
||||
organizations := []*coredata.Organization{}
|
||||
|
||||
err := s.pg.WithConn(
|
||||
ctx,
|
||||
func(conn pg.Conn) error {
|
||||
if err := uos.ForUserID(ctx, conn, userID); err != nil {
|
||||
return fmt.Errorf("cannot list user organizations: %w", err)
|
||||
}
|
||||
|
||||
for _, uo := range uos {
|
||||
scope := coredata.NewScope(uo.OrganizationID.TenantID())
|
||||
organization := &coredata.Organization{}
|
||||
if err := organization.LoadByID(ctx, conn, scope, uo.OrganizationID); err != nil {
|
||||
return fmt.Errorf("cannot load organization by id: %w", err)
|
||||
}
|
||||
organizations = append(organizations, organization)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return organizations, nil
|
||||
}
|
||||
|
||||
// Tenant id scope is not applied in this functions because we want to access all user's organizations.
|
||||
func (s Service) ListOrganizationsForUserIDPaginated(
|
||||
ctx context.Context,
|
||||
userID gid.GID,
|
||||
cursor *page.Cursor[coredata.OrganizationOrderField],
|
||||
) (coredata.Organizations, error) {
|
||||
organizations := coredata.Organizations{}
|
||||
|
||||
err := s.pg.WithConn(
|
||||
ctx,
|
||||
func(conn pg.Conn) error {
|
||||
err := organizations.ListForUserID(ctx, conn, userID, cursor)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot list user organizations: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return organizations, nil
|
||||
}
|
||||
|
||||
func (s Service) ListTenantsForUserID(
|
||||
ctx context.Context,
|
||||
userID gid.GID,
|
||||
) ([]gid.TenantID, error) {
|
||||
|
||||
uos := coredata.UserOrganizations{}
|
||||
|
||||
err := s.pg.WithConn(
|
||||
ctx,
|
||||
func(tx pg.Conn) error {
|
||||
return uos.ForUserID(ctx, tx, userID)
|
||||
},
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
tenantIDs := make([]gid.TenantID, len(uos))
|
||||
for _, uo := range uos {
|
||||
tenantIDs = append(tenantIDs, uo.OrganizationID.TenantID())
|
||||
}
|
||||
|
||||
return tenantIDs, nil
|
||||
}
|
||||
|
||||
func (s Service) EnrollUserInOrganization(
|
||||
ctx context.Context,
|
||||
userID gid.GID,
|
||||
organizationID gid.GID,
|
||||
) error {
|
||||
|
||||
uo := coredata.UserOrganization{
|
||||
UserID: userID,
|
||||
OrganizationID: organizationID,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
|
||||
return s.pg.WithConn(
|
||||
ctx,
|
||||
func(tx pg.Conn) error {
|
||||
return uo.Insert(ctx, tx)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
func (s Service) UpdateSession(
|
||||
ctx context.Context,
|
||||
session *coredata.Session,
|
||||
) error {
|
||||
session.UpdatedAt = time.Now()
|
||||
session.ExpiredAt = time.Now().Add(24 * time.Hour)
|
||||
|
||||
return s.pg.WithTx(
|
||||
ctx,
|
||||
func(tx pg.Conn) error {
|
||||
return session.Update(ctx, tx)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
func (s Service) ConfirmEmail(ctx context.Context, tokenString string) error {
|
||||
token, err := statelesstoken.ValidateToken[EmailConfirmationData](
|
||||
s.tokenSecret,
|
||||
TokenTypeEmailConfirmation,
|
||||
tokenString,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot validate email confirmation token: %w", err)
|
||||
}
|
||||
|
||||
return s.pg.WithTx(
|
||||
ctx,
|
||||
func(tx pg.Conn) error {
|
||||
user := &coredata.User{}
|
||||
|
||||
if err := user.LoadByID(ctx, tx, token.Data.UserID); err != nil {
|
||||
return fmt.Errorf("user not found: %w", err)
|
||||
}
|
||||
|
||||
if user.EmailAddress != token.Data.Email {
|
||||
return fmt.Errorf("token email does not match user email")
|
||||
}
|
||||
|
||||
if err := user.UpdateEmailVerification(ctx, tx, true); err != nil {
|
||||
return fmt.Errorf("cannot update user email verification: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
func (s Service) ListUsersForTenant(
|
||||
ctx context.Context,
|
||||
organizationID gid.GID,
|
||||
cursor *page.Cursor[coredata.UserOrderField],
|
||||
) (*page.Page[*coredata.User, coredata.UserOrderField], error) {
|
||||
users := coredata.Users{}
|
||||
|
||||
err := s.pg.WithConn(
|
||||
ctx,
|
||||
func(tx pg.Conn) error {
|
||||
return users.LoadByOrganizationID(ctx, tx, organizationID, cursor)
|
||||
},
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return page.NewPage(users, cursor), nil
|
||||
}
|
||||
|
||||
func (s Service) InviteUser(
|
||||
ctx context.Context,
|
||||
organizationID gid.GID,
|
||||
fullName string,
|
||||
emailAddress string,
|
||||
createPeople bool,
|
||||
) error {
|
||||
if _, err := mail.ParseAddress(emailAddress); err != nil {
|
||||
return &ErrInvalidEmail{emailAddress}
|
||||
}
|
||||
if fullName == "" {
|
||||
return &ErrInvalidFullName{fullName}
|
||||
}
|
||||
|
||||
var userExists bool
|
||||
err := s.pg.WithConn(
|
||||
ctx,
|
||||
func(tx pg.Conn) error {
|
||||
user := &coredata.User{}
|
||||
|
||||
if err := user.LoadByEmail(ctx, tx, emailAddress); err != nil {
|
||||
var errUserNotFound *coredata.ErrUserNotFound
|
||||
|
||||
if errors.As(err, &errUserNotFound) {
|
||||
userExists = false
|
||||
return nil
|
||||
}
|
||||
|
||||
return fmt.Errorf("cannot load user by email: %w", err)
|
||||
}
|
||||
|
||||
userExists = true
|
||||
uo := coredata.UserOrganization{
|
||||
UserID: user.ID,
|
||||
OrganizationID: organizationID,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
|
||||
if err := uo.Insert(ctx, tx); err != nil {
|
||||
return fmt.Errorf("cannot insert user organization: %w", err)
|
||||
}
|
||||
|
||||
if createPeople {
|
||||
people := &coredata.People{}
|
||||
scope := coredata.NewScope(organizationID.TenantID())
|
||||
if err := people.LoadByEmail(ctx, tx, scope, emailAddress); err != nil {
|
||||
var errPeopleNotFound *coredata.ErrPeopleNotFound
|
||||
|
||||
if errors.As(err, &errPeopleNotFound) {
|
||||
people = &coredata.People{
|
||||
ID: gid.New(organizationID.TenantID(), coredata.PeopleEntityType),
|
||||
OrganizationID: organizationID,
|
||||
UserID: &user.ID,
|
||||
FullName: fullName,
|
||||
PrimaryEmailAddress: emailAddress,
|
||||
Kind: coredata.PeopleKindContractor,
|
||||
AdditionalEmailAddresses: []string{},
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
|
||||
if err := people.Insert(ctx, tx, scope); err != nil {
|
||||
return fmt.Errorf("cannot insert people: %w", err)
|
||||
}
|
||||
} else {
|
||||
return fmt.Errorf("cannot load people by email: %w", err)
|
||||
}
|
||||
} else {
|
||||
people.UserID = &user.ID
|
||||
people.FullName = fullName
|
||||
people.UpdatedAt = time.Now()
|
||||
|
||||
if err := people.Update(ctx, tx, scope); err != nil {
|
||||
return fmt.Errorf("cannot update people: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if userExists {
|
||||
return nil
|
||||
}
|
||||
|
||||
confirmationToken, err := statelesstoken.NewToken(
|
||||
s.tokenSecret,
|
||||
TokenTypeOrganizationInvitation,
|
||||
s.invitationTokenValidity,
|
||||
InvitationData{OrganizationID: organizationID, Email: emailAddress, FullName: fullName, CreatePeople: createPeople},
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot generate confirmation token: %w", err)
|
||||
}
|
||||
|
||||
confirmationInvitationUrl := url.URL{
|
||||
Scheme: "https",
|
||||
Host: s.hostname,
|
||||
Path: "/auth/confirm-invitation",
|
||||
RawQuery: url.Values{
|
||||
"token": []string{confirmationToken},
|
||||
}.Encode(),
|
||||
}
|
||||
|
||||
confirmationEmail := coredata.NewEmail(
|
||||
fullName,
|
||||
emailAddress,
|
||||
invitationEmailSubject,
|
||||
fmt.Sprintf(invitationEmailTemplate, confirmationInvitationUrl.String()),
|
||||
)
|
||||
|
||||
return s.pg.WithConn(
|
||||
ctx,
|
||||
func(conn pg.Conn) error {
|
||||
if err := confirmationEmail.Insert(ctx, conn); err != nil {
|
||||
return fmt.Errorf("cannot insert email: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
func (s Service) ConfirmInvitation(ctx context.Context, tokenString string, password string) (*coredata.User, error) {
|
||||
token, err := statelesstoken.ValidateToken[InvitationData](
|
||||
s.tokenSecret,
|
||||
TokenTypeOrganizationInvitation,
|
||||
tokenString,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot validate organization invitation token: %w", err)
|
||||
}
|
||||
|
||||
if len(password) < 8 || len(password) > 128 {
|
||||
return nil, &ErrInvalidPassword{minLength: 8, maxLength: 128}
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
|
||||
hashedPassword, err := s.hp.HashPassword([]byte(password))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot hash password: %w", err)
|
||||
}
|
||||
|
||||
user := &coredata.User{}
|
||||
|
||||
err = s.pg.WithTx(
|
||||
ctx,
|
||||
func(tx pg.Conn) error {
|
||||
|
||||
if err := user.LoadByEmail(ctx, tx, token.Data.Email); err != nil {
|
||||
var errUserNotFound *coredata.ErrUserNotFound
|
||||
|
||||
if errors.As(err, &errUserNotFound) {
|
||||
user = &coredata.User{
|
||||
ID: gid.New(gid.NilTenant, coredata.UserEntityType),
|
||||
EmailAddress: token.Data.Email,
|
||||
HashedPassword: hashedPassword,
|
||||
EmailAddressVerified: true,
|
||||
FullName: token.Data.FullName,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
|
||||
if err := user.Insert(ctx, tx); err != nil {
|
||||
return fmt.Errorf("cannot insert user: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
uo := coredata.UserOrganization{
|
||||
UserID: user.ID,
|
||||
OrganizationID: token.Data.OrganizationID,
|
||||
CreatedAt: now,
|
||||
}
|
||||
|
||||
if err := uo.Insert(ctx, tx); err != nil {
|
||||
return fmt.Errorf("cannot insert user organization: %w", err)
|
||||
}
|
||||
|
||||
if token.Data.CreatePeople {
|
||||
people := &coredata.People{}
|
||||
scope := coredata.NewScope(token.Data.OrganizationID.TenantID())
|
||||
|
||||
if err := people.LoadByEmail(ctx, tx, scope, token.Data.Email); err != nil {
|
||||
var errPeopleNotFound *coredata.ErrPeopleNotFound
|
||||
|
||||
if errors.As(err, &errPeopleNotFound) {
|
||||
peopleID := gid.New(token.Data.OrganizationID.TenantID(), coredata.PeopleEntityType)
|
||||
people = &coredata.People{
|
||||
ID: peopleID,
|
||||
OrganizationID: token.Data.OrganizationID,
|
||||
UserID: &user.ID,
|
||||
FullName: token.Data.FullName,
|
||||
PrimaryEmailAddress: token.Data.Email,
|
||||
Kind: coredata.PeopleKindEmployee,
|
||||
AdditionalEmailAddresses: []string{},
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
|
||||
if err := people.Insert(ctx, tx, scope); err != nil {
|
||||
return fmt.Errorf("cannot insert people: %w", err)
|
||||
}
|
||||
} else {
|
||||
return fmt.Errorf("cannot load people by email: %w", err)
|
||||
}
|
||||
} else {
|
||||
people.UserID = &user.ID
|
||||
people.FullName = token.Data.FullName
|
||||
people.UpdatedAt = now
|
||||
|
||||
if err := people.Update(ctx, tx, scope); err != nil {
|
||||
return fmt.Errorf("cannot update people: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func (s Service) RemoveUser(ctx context.Context, organizationID gid.GID, userID gid.GID) error {
|
||||
return s.pg.WithConn(
|
||||
ctx,
|
||||
func(tx pg.Conn) error {
|
||||
uo := coredata.UserOrganization{
|
||||
UserID: userID,
|
||||
OrganizationID: organizationID,
|
||||
}
|
||||
|
||||
if err := uo.Delete(ctx, tx); err != nil {
|
||||
return fmt.Errorf("cannot delete user organization: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
func (s Service) ResetPassword(ctx context.Context, tokenString string, newPassword string) error {
|
||||
token, err := statelesstoken.ValidateToken[PasswordResetData](
|
||||
s.tokenSecret,
|
||||
TokenTypePasswordReset,
|
||||
tokenString,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot validate password reset token: %w", err)
|
||||
}
|
||||
|
||||
if len(newPassword) < 8 || len(newPassword) > 128 {
|
||||
return &ErrInvalidPassword{minLength: 8, maxLength: 128}
|
||||
}
|
||||
|
||||
hashedPassword, err := s.hp.HashPassword([]byte(newPassword))
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot hash password: %w", err)
|
||||
}
|
||||
|
||||
return s.pg.WithTx(
|
||||
ctx,
|
||||
func(tx pg.Conn) error {
|
||||
user := &coredata.User{}
|
||||
|
||||
if err := user.LoadByEmail(ctx, tx, token.Data.Email); err != nil {
|
||||
var errUserNotFound *coredata.ErrUserNotFound
|
||||
|
||||
if errors.As(err, &errUserNotFound) {
|
||||
return fmt.Errorf("user not found: %w", err)
|
||||
}
|
||||
|
||||
return fmt.Errorf("cannot load user by email: %w", err)
|
||||
}
|
||||
|
||||
if err := user.UpdatePassword(ctx, tx, hashedPassword); err != nil {
|
||||
return fmt.Errorf("cannot update user password: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user