Files
probo/pkg/authz/service.go
Sacha Al Himdani 44cf6737b5 Fix document permission
Signed-off-by: Sacha Al Himdani <sacha@getprobo.com>
2025-11-14 16:32:58 +01:00

933 lines
22 KiB
Go

// 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 (
"context"
"errors"
"fmt"
"net/url"
"slices"
"time"
"go.gearno.de/kit/pg"
"go.probo.inc/probo/packages/emails"
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/page"
"go.probo.inc/probo/pkg/statelesstoken"
)
type TenantAccessError struct {
Message string
}
func (e *TenantAccessError) Error() string {
return "not authorized"
}
type PermissionDeniedError struct {
Message string
}
func (e *PermissionDeniedError) Error() string {
return e.Message
}
type (
Service struct {
pg *pg.Client
baseURL string
tokenSecret string
invitationTokenValidity time.Duration
}
TenantAuthzService struct {
pg *pg.Client
baseURL string
tokenSecret string
invitationTokenValidity time.Duration
scope coredata.Scoper
}
)
const (
TokenTypeOrganizationInvitation = "organization_invitation"
)
func NewService(
ctx context.Context,
pgClient *pg.Client,
baseURL string,
tokenSecret string,
invitationTokenValidity time.Duration,
) (*Service, error) {
return &Service{
pg: pgClient,
baseURL: baseURL,
tokenSecret: tokenSecret,
invitationTokenValidity: invitationTokenValidity,
}, nil
}
func (s *Service) WithTenant(tenantID gid.TenantID) *TenantAuthzService {
return &TenantAuthzService{
pg: s.pg,
baseURL: s.baseURL,
tokenSecret: s.tokenSecret,
invitationTokenValidity: s.invitationTokenValidity,
scope: coredata.NewScope(tenantID),
}
}
func (s *Service) GetAllUserOrganizations(
ctx context.Context,
userID gid.GID,
) (coredata.Organizations, error) {
organizations := coredata.Organizations{}
err := s.pg.WithConn(
ctx,
func(conn pg.Conn) error {
if err := organizations.LoadAllByUserID(ctx, conn, userID); err != nil {
return fmt.Errorf("cannot load user organizations: %w", err)
}
return nil
},
)
return organizations, err
}
func (s *Service) GetUserOrganizationsWithRole(
ctx context.Context,
userID gid.GID,
role coredata.MembershipRole,
) (coredata.Organizations, error) {
organizations := coredata.Organizations{}
err := s.pg.WithConn(
ctx,
func(conn pg.Conn) error {
if err := organizations.LoadAllByUserIDWithRole(ctx, conn, userID, role); err != nil {
return fmt.Errorf("cannot load user organizations with role: %w", err)
}
return nil
},
)
return organizations, err
}
func (s *Service) GetAllOrganizationsForUserAPIKeyId(
ctx context.Context,
userAPIKeyID gid.GID,
) (coredata.Organizations, error) {
organizations := coredata.Organizations{}
err := s.pg.WithConn(
ctx,
func(conn pg.Conn) error {
if err := organizations.LoadAllByUserAPIKeyID(ctx, conn, userAPIKeyID); err != nil {
return fmt.Errorf("cannot load user api key organizations: %w", err)
}
return nil
},
)
return organizations, err
}
func (s *Service) GetUserOrganizations(
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 {
if err := organizations.LoadByUserID(ctx, conn, coredata.NewNoScope(), userID, cursor); err != nil {
return fmt.Errorf("cannot load user organizations: %w", err)
}
return nil
},
)
return organizations, err
}
func (s *Service) AcceptInvitationByID(
ctx context.Context,
invitationID gid.GID,
userID gid.GID,
) (*coredata.Invitation, error) {
var acceptedInvitation *coredata.Invitation
scope := coredata.NewScope(invitationID.TenantID())
err := s.pg.WithTx(
ctx,
func(tx pg.Conn) error {
invitation := &coredata.Invitation{}
if err := invitation.LoadByID(ctx, tx, scope, 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")
}
user := &coredata.User{}
if err := user.LoadByID(ctx, tx, userID); err != nil {
return fmt.Errorf("cannot load user: %w", err)
}
if invitation.Email != user.EmailAddress {
return fmt.Errorf("invitation email does not match user email")
}
now := time.Now()
membershipID := gid.New(scope.GetTenantID(), coredata.MembershipEntityType)
membership := &coredata.Membership{
ID: membershipID,
UserID: userID,
OrganizationID: invitation.OrganizationID,
Role: invitation.Role,
CreatedAt: now,
UpdatedAt: now,
}
if err := membership.Create(ctx, tx, scope); err != nil {
return fmt.Errorf("cannot add user to organization: %w", err)
}
invitation.AcceptedAt = &now
if err := invitation.Update(ctx, tx, scope); err != nil {
return fmt.Errorf("cannot mark invitation as accepted: %w", err)
}
acceptedInvitation = invitation
return nil
},
)
if err != nil {
return nil, err
}
return acceptedInvitation, nil
}
type UserInvitation struct {
ID gid.GID
Email string
FullName string
Role coredata.MembershipRole
ExpiresAt time.Time
AcceptedAt *time.Time
CreatedAt time.Time
OrganizationID gid.GID
Organization OrganizationSummary
}
type OrganizationSummary struct {
ID gid.GID
Name string
}
func (s *Service) GetUserPendingInvitations(
ctx context.Context,
email string,
) ([]*UserInvitation, error) {
userInvitations := []*UserInvitation{}
err := s.pg.WithConn(
ctx,
func(conn pg.Conn) error {
cursor := page.NewCursor(
1000,
nil,
page.Head,
page.OrderBy[coredata.InvitationOrderField]{
Field: coredata.InvitationOrderFieldCreatedAt,
Direction: page.OrderDirectionDesc,
},
)
filter := coredata.NewInvitationFilter([]coredata.InvitationStatus{coredata.InvitationStatusPending})
invitations := coredata.Invitations{}
if err := invitations.LoadByEmail(ctx, conn, coredata.NewNoScope(), email, cursor, filter); err != nil {
return fmt.Errorf("cannot load invitations: %w", err)
}
organizationIDs := []gid.GID{}
for _, invitation := range invitations {
organizationIDs = append(organizationIDs, invitation.OrganizationID)
}
organizations := coredata.Organizations{}
if err := organizations.BatchLoadByID(ctx, conn, coredata.NewNoScope(), organizationIDs); err != nil {
return fmt.Errorf("cannot load organizations: %w", err)
}
for _, invitation := range invitations {
userInvitation := &UserInvitation{
ID: invitation.ID,
Email: invitation.Email,
FullName: invitation.FullName,
Role: invitation.Role,
ExpiresAt: invitation.ExpiresAt,
AcceptedAt: invitation.AcceptedAt,
CreatedAt: invitation.CreatedAt,
OrganizationID: invitation.OrganizationID,
}
for _, org := range organizations {
if org.ID == invitation.OrganizationID {
userInvitation.Organization = OrganizationSummary{
ID: org.ID,
Name: org.Name,
}
}
}
userInvitations = append(userInvitations, userInvitation)
}
return nil
},
)
if err != nil {
return nil, err
}
return userInvitations, nil
}
func (s *TenantAuthzService) GetOrganizationByInvitationID(
ctx context.Context,
invitationID gid.GID,
) (*coredata.Organization, error) {
var organization coredata.Organization
err := s.pg.WithConn(
ctx,
func(conn pg.Conn) error {
var invitation coredata.Invitation
if err := invitation.LoadByID(ctx, conn, s.scope, invitationID); err != nil {
return fmt.Errorf("cannot load invitation: %w", err)
}
if err := organization.LoadByID(ctx, conn, s.scope, invitation.OrganizationID); err != nil {
return fmt.Errorf("cannot load organization: %w", err)
}
return nil
},
)
if err != nil {
return nil, err
}
return &organization, nil
}
func (s *TenantAuthzService) AddUserToOrganization(
ctx context.Context,
userID gid.GID,
orgID gid.GID,
role coredata.MembershipRole,
) error {
now := time.Now()
membershipID := gid.New(s.scope.GetTenantID(), coredata.MembershipEntityType)
membership := &coredata.Membership{
ID: membershipID,
UserID: userID,
OrganizationID: orgID,
Role: role,
CreatedAt: now,
UpdatedAt: now,
}
return s.pg.WithConn(
ctx,
func(conn pg.Conn) error {
if err := membership.Create(ctx, conn, s.scope); err != nil {
return fmt.Errorf("cannot add user to organization: %w", err)
}
return nil
},
)
}
func (s *TenantAuthzService) GetInvitationsByOrganizationID(
ctx context.Context,
orgID gid.GID,
cursor *page.Cursor[coredata.InvitationOrderField],
filter *coredata.InvitationFilter,
) (*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, s.scope, orgID, cursor, filter); err != nil {
return fmt.Errorf("cannot load organization invitations: %w", err)
}
return nil
},
)
if err != nil {
return nil, err
}
return page.NewPage(invitations, cursor), nil
}
func (s *TenantAuthzService) CountOrganizationInvitations(
ctx context.Context,
orgID gid.GID,
filter *coredata.InvitationFilter,
) (int, error) {
var count int
err := s.pg.WithConn(
ctx,
func(conn pg.Conn) (err error) {
var invitations coredata.Invitations
count, err = invitations.CountByOrganizationID(ctx, conn, s.scope, orgID, filter)
if err != nil {
return fmt.Errorf("cannot count organization invitations: %w", err)
}
return nil
},
)
if err != nil {
return 0, err
}
return count, nil
}
func (s *TenantAuthzService) GetInvitationByID(
ctx context.Context,
invitationID gid.GID,
) (*coredata.Invitation, error) {
invitation := &coredata.Invitation{}
err := s.pg.WithConn(
ctx,
func(conn pg.Conn) error {
if err := invitation.LoadByID(ctx, conn, s.scope, invitationID); err != nil {
return fmt.Errorf("cannot load invitation: %w", err)
}
return nil
},
)
if err != nil {
return nil, err
}
return invitation, nil
}
func (s *TenantAuthzService) 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, s.scope, invitationID); err != nil {
return fmt.Errorf("cannot load invitation: %w", err)
}
if err := invitation.Delete(ctx, conn, s.scope); err != nil {
return fmt.Errorf("cannot delete invitation: %w", err)
}
return nil
},
)
}
func (s *TenantAuthzService) GetMembershipByUserAndOrganizationID(
ctx context.Context,
userID gid.GID,
orgID gid.GID,
) (*coredata.Membership, error) {
membership := &coredata.Membership{}
err := s.pg.WithConn(
ctx,
func(conn pg.Conn) error {
if err := membership.LoadByUserAndOrg(ctx, conn, s.scope, userID, orgID); err != nil {
return fmt.Errorf("cannot load membership: %w", err)
}
return nil
},
)
if err != nil {
return nil, err
}
return membership, nil
}
func (s *TenantAuthzService) GetMembershipsByOrganizationID(
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, s.scope, orgID, cursor); err != nil {
return fmt.Errorf("cannot load organization memberships: %w", err)
}
return nil
},
)
if err != nil {
return nil, err
}
return page.NewPage(memberships, cursor), nil
}
func (s *TenantAuthzService) 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, s.scope, orgID)
return err
},
)
if err != nil {
return 0, fmt.Errorf("cannot count memberships: %w", err)
}
return count, nil
}
func (s *TenantAuthzService) CountOrganizationUsers(
ctx context.Context,
orgID gid.GID,
) (int, error) {
var count int
err := s.pg.WithConn(
ctx,
func(conn pg.Conn) (err error) {
var users coredata.Users
count, err = users.CountByOrganizationID(ctx, conn, s.scope, orgID)
if err != nil {
return fmt.Errorf("cannot count organization users: %w", err)
}
return nil
},
)
if err != nil {
return 0, fmt.Errorf("cannot count users: %w", err)
}
return count, nil
}
func (s *TenantAuthzService) GetUserRoleInOrganization(
ctx context.Context,
userID gid.GID,
orgID gid.GID,
) (coredata.MembershipRole, error) {
membership := &coredata.Membership{}
err := s.pg.WithConn(
ctx,
func(conn pg.Conn) error {
if err := membership.LoadByUserAndOrg(ctx, conn, s.scope, userID, orgID); err != nil {
return fmt.Errorf("cannot get user role: %w", err)
}
return nil
},
)
if err != nil {
return "", err
}
return membership.Role, nil
}
func (s *TenantAuthzService) 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, s.scope, memberID); err != nil {
return fmt.Errorf("cannot load membership: %w", err)
}
if membership.OrganizationID != orgID {
return fmt.Errorf("membership does not belong to organization")
}
if err := membership.Delete(ctx, tx, s.scope); err != nil {
return fmt.Errorf("cannot delete membership: %w", err)
}
return nil
},
)
}
func (s *TenantAuthzService) UpdateMembershipRole(
ctx context.Context,
orgID gid.GID,
memberID gid.GID,
newRole coredata.MembershipRole,
) (*coredata.Membership, error) {
membership := &coredata.Membership{}
err := s.pg.WithTx(
ctx,
func(tx pg.Conn) error {
if err := membership.LoadByID(ctx, tx, s.scope, memberID); err != nil {
return fmt.Errorf("cannot load membership: %w", err)
}
if membership.OrganizationID != orgID {
return fmt.Errorf("membership does not belong to organization")
}
// If the new role cannot create API keys, delete all related API key memberships
if newRole != coredata.MembershipRoleOwner {
var apiKeyMemberships coredata.UserAPIKeyMemberships
if err := apiKeyMemberships.LoadByMembershipID(ctx, tx, s.scope, memberID); err != nil {
return fmt.Errorf("cannot load api key memberships: %w", err)
}
for _, apiKeyMembership := range apiKeyMemberships {
if err := apiKeyMembership.Delete(ctx, tx, s.scope); err != nil {
return fmt.Errorf("cannot delete api key membership: %w", err)
}
}
}
membership.Role = newRole
membership.UpdatedAt = time.Now()
if err := membership.Update(ctx, tx, s.scope); err != nil {
return fmt.Errorf("cannot update membership role: %w", err)
}
return nil
},
)
if err != nil {
return nil, err
}
return membership, nil
}
func (s *TenantAuthzService) InviteUserToOrganization(
ctx context.Context,
organizationID gid.GID,
emailAddress string,
fullName string,
role coredata.MembershipRole,
) (*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("cannot check if user exists: %w", err)
}
}
organization := &coredata.Organization{}
if err := organization.LoadByID(ctx, tx, s.scope, organizationID); err != nil {
return fmt.Errorf("cannot load organization: %w", err)
}
invitationID := gid.New(s.scope.GetTenantID(), 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,
}
var err error
var invitationURL string
var recipientName string
if userExists {
recipientName = user.FullName
invitationURL = s.baseURL + "/"
} else {
recipientName = fullName
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("cannot generate invitation token: %w", err)
}
invitationURL = fmt.Sprintf("%s/auth/signup-from-invitation?token=%s&fullName=%s", s.baseURL, invitationToken, url.QueryEscape(fullName))
}
subject, textBody, htmlBody, err := emails.RenderInvitation(
s.baseURL,
recipientName,
organization.Name,
invitationURL,
)
if err != nil {
return fmt.Errorf("cannot render invitation email: %w", err)
}
email := coredata.NewEmail(
fullName,
emailAddress,
subject,
textBody,
htmlBody,
)
if err := email.Insert(ctx, tx); err != nil {
return fmt.Errorf("cannot insert email: %w", err)
}
if err := invitation.Create(ctx, tx, s.scope); err != nil {
return fmt.Errorf("cannot create invitation: %w", err)
}
return nil
},
)
if err != nil {
return nil, err
}
return invitation, nil
}
func (s *TenantAuthzService) EnsureSAMLMembership(
ctx context.Context,
userID gid.GID,
organizationID gid.GID,
role *coredata.MembershipRole,
) error {
now := time.Now()
return s.pg.WithTx(
ctx,
func(tx pg.Conn) error {
var membership coredata.Membership
err := membership.LoadByUserAndOrg(ctx, tx, s.scope, userID, organizationID)
if err != nil {
if _, ok := err.(coredata.ErrMembershipNotFound); !ok {
return fmt.Errorf("cannot load membership: %w", err)
}
membershipRole := coredata.MembershipRoleViewer
if role != nil {
membershipRole = *role
}
membershipID := gid.New(s.scope.GetTenantID(), coredata.MembershipEntityType)
membership = coredata.Membership{
ID: membershipID,
UserID: userID,
OrganizationID: organizationID,
Role: membershipRole,
CreatedAt: now,
UpdatedAt: now,
}
if err := membership.Create(ctx, tx, s.scope); err != nil {
return fmt.Errorf("cannot create membership: %w", err)
}
return nil
}
if role != nil && membership.Role != *role {
membership.Role = *role
membership.UpdatedAt = now
if err := membership.Update(ctx, tx, s.scope); err != nil {
return fmt.Errorf("cannot update membership role: %w", err)
}
}
return nil
},
)
}
func (s *TenantAuthzService) Authorize(
ctx context.Context,
user *coredata.User,
apiKey *coredata.UserAPIKey,
entityGID gid.GID,
action Action,
) error {
requiredRoles := GetPermissionsForAction(entityGID.EntityType(), action)
if requiredRoles == nil {
entityModel, _ := coredata.EntityModel(entityGID.EntityType())
return &PermissionDeniedError{
Message: fmt.Sprintf("no permissions defined for action %s on entity %s", action, entityModel),
}
}
role, err := s.GetUserOrAPIKeyRole(ctx, user, apiKey, entityGID)
if err != nil {
return fmt.Errorf("cannot get user or API key role: %w", err)
}
if !slices.Contains(requiredRoles, role) {
return &PermissionDeniedError{
Message: fmt.Sprintf("role %s not authorized for action %s, requires one of %v", role, action, requiredRoles),
}
}
return nil
}
func (s *TenantAuthzService) CanAssignRole(
ctx context.Context,
user *coredata.User,
apiKey *coredata.UserAPIKey,
entityGID gid.GID,
targetRole coredata.MembershipRole,
) error {
currentRole, err := s.GetUserOrAPIKeyRole(ctx, user, apiKey, entityGID)
if err != nil {
return fmt.Errorf("cannot get user or API key role: %w", err)
}
if currentRole == RoleOwner || currentRole == RoleFull {
return nil
}
if currentRole == RoleAdmin {
if targetRole == coredata.MembershipRoleOwner {
return &PermissionDeniedError{Message: "admin users cannot assign owner role"}
}
return nil
}
return &PermissionDeniedError{Message: fmt.Sprintf("role %s cannot assign roles", currentRole)}
}
func (s *TenantAuthzService) GetUserOrAPIKeyRole(
ctx context.Context,
user *coredata.User,
apiKey *coredata.UserAPIKey,
entityGID gid.GID,
) (Role, error) {
var role Role
err := s.pg.WithConn(
ctx,
func(conn pg.Conn) error {
if user != nil {
membership := &coredata.Membership{}
if err := membership.LoadRoleByUserAndEntityID(ctx, conn, s.scope, user.ID, entityGID); err != nil {
return fmt.Errorf("cannot get user role: %w", err)
}
role = Role(membership.Role.String())
return nil
}
if apiKey != nil {
apiKeyMembership := &coredata.UserAPIKeyMembership{}
if err := apiKeyMembership.LoadRoleByAPIKeyAndEntityID(ctx, conn, s.scope, apiKey.ID, entityGID); err != nil {
return fmt.Errorf("cannot get API key role: %w", err)
}
role = Role(apiKeyMembership.Role.String())
return nil
}
return fmt.Errorf("no user or API key provided")
},
)
if err != nil {
return "", err
}
return role, nil
}