Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ TAG := $(shell git rev-list --tags --max-count=1)
VERSION := $(shell git describe --tags ${TAG})
.PHONY: build check fmt lint test test-race vet test-cover-html help install proto admin-app compose-up-dev
.DEFAULT_GOAL := build
PROTON_COMMIT := "795c70f359264a3c21a4c6412097f139dfc387e6"
PROTON_COMMIT := "ee05c27600bd7d2782da4fde0997f84aa69e7eeb"

admin-app:
@echo " > generating admin build"
Expand Down
4 changes: 2 additions & 2 deletions cmd/serve.go
Original file line number Diff line number Diff line change
Expand Up @@ -395,7 +395,7 @@ func buildAPIDependencies(

svUserRepo := postgres.NewServiceUserRepository(dbc)
scUserCredRepo := postgres.NewServiceUserCredentialRepository(dbc)
serviceUserService := serviceuser.NewService(logger, svUserRepo, scUserCredRepo, relationService)
serviceUserService := serviceuser.NewService(logger, svUserRepo, scUserCredRepo, relationService, auditRecordRepository)

var mailDialer mailer.Dialer = mailer.NewMockDialer()
if cfg.App.Mailer.SMTPHost != "" && cfg.App.Mailer.SMTPHost != "smtp.example.com" {
Expand Down Expand Up @@ -432,7 +432,7 @@ func buildAPIDependencies(
// back here because role.Service depends on permission.Service
permissionService.SetRoleService(roleService)
policyService := policy.NewService(policyPGRepository, relationService, roleService)
userService := user.NewService(userRepository, relationService, sessionService)
userService := user.NewService(userRepository, relationService, sessionService, auditRecordRepository)
patValidator := userpat.NewValidator(logger, userPATRepo, cfg.App.PAT)
authnService := authenticate.NewService(logger, cfg.App.Authentication,
postgres.NewFlowRepository(logger, dbc), mailDialer, tokenService, sessionService, userService, serviceUserService, webAuthConfig, patValidator)
Expand Down
94 changes: 94 additions & 0 deletions core/serviceuser/mocks/audit_record_repository.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

116 changes: 86 additions & 30 deletions core/serviceuser/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"fmt"
"io"
"log/slog"
"time"

"golang.org/x/crypto/sha3"

Expand All @@ -18,8 +19,10 @@ import (
"github.com/google/uuid"
"github.com/lestrrat-go/jwx/v2/jwk"
"github.com/lestrrat-go/jwx/v2/jwt"
"github.com/raystack/frontier/core/auditrecord/models"
"github.com/raystack/frontier/core/relation"
"github.com/raystack/frontier/internal/bootstrap/schema"
pkgAuditRecord "github.com/raystack/frontier/pkg/auditrecord"
"github.com/raystack/frontier/pkg/utils"
)

Expand Down Expand Up @@ -51,20 +54,26 @@ type MembershipService interface {
ListPrincipalIDsByResource(ctx context.Context, resourceID, resourceType, principalType string) ([]string, error)
}

type AuditRecordRepository interface {
Create(ctx context.Context, auditRecord models.AuditRecord) (models.AuditRecord, error)
}

type Service struct {
log *slog.Logger
repo Repository
credRepo CredentialRepository
relationService RelationService
membershipService MembershipService
log *slog.Logger
repo Repository
credRepo CredentialRepository
relationService RelationService
membershipService MembershipService
auditRecordRepository AuditRecordRepository
}

func NewService(logger *slog.Logger, repo Repository, credRepo CredentialRepository, relService RelationService) *Service {
func NewService(logger *slog.Logger, repo Repository, credRepo CredentialRepository, relService RelationService, auditRecordRepository AuditRecordRepository) *Service {
return &Service{
log: logger,
repo: repo,
credRepo: credRepo,
relationService: relService,
log: logger,
repo: repo,
credRepo: credRepo,
relationService: relService,
auditRecordRepository: auditRecordRepository,
}
}

Expand Down Expand Up @@ -437,19 +446,19 @@ func (s Service) Sudo(ctx context.Context, id string, relationName string) error
return err
}

// check if already su
permissionName := ""
// validate the requested platform relation
switch relationName {
case schema.MemberRelationName:
permissionName = schema.PlatformCheckPermission
case schema.AdminRelationName:
permissionName = schema.PlatformSudoPermission
}
if permissionName == "" {
case schema.MemberRelationName, schema.AdminRelationName:
default:
return fmt.Errorf("invalid relation name, possible options are: %s, %s", schema.MemberRelationName, schema.AdminRelationName)
}

if ok, err := s.IsSudo(ctx, currentUser.ID, permissionName); err != nil {
// Idempotent on the exact relation tuple, not the derived permission:
// `check` is granted by both admin and member, so a permission check would
// skip creating the member tuple for an existing admin. That breaks
// downgrade (Sudo member then UnSudo admin), which UnSudo evaluates at the
// relation level — keep the two symmetric.
if ok, err := s.IsSudo(ctx, currentUser.ID, relationName); err != nil {
return err
} else if ok {
return nil
Expand All @@ -467,29 +476,46 @@ func (s Service) Sudo(ctx context.Context, id string, relationName string) error
},
RelationName: relationName,
})
return err
if err != nil {
return err
}

// audit the grant for both admin and member relations
event := pkgAuditRecord.PlatformAdminAddedEvent
if relationName == schema.MemberRelationName {
event = pkgAuditRecord.PlatformMemberAddedEvent
}
return s.recordPlatformAuditRecord(ctx, currentUser, event, relationName)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

// UnSudo remove platform permissions to user
// only remove the 'member' relation if it exists
func (s Service) UnSudo(ctx context.Context, id string) error {
// UnSudo removes a platform relation (admin or member) from a service user.
// It removes the exact relation requested — an `admin` relation can now actually
// be stripped. Both admin and member grants/removals are audited.
func (s Service) UnSudo(ctx context.Context, id, relationName string) error {
switch relationName {
case schema.AdminRelationName, schema.MemberRelationName:
default:
return fmt.Errorf("invalid relation name, possible options are: %s, %s", schema.MemberRelationName, schema.AdminRelationName)
}

currentUser, err := s.Get(ctx, id)
if err != nil {
return err
}

relationName := schema.MemberRelationName
// to check if the user has member relation, we need to check if the user has `check`
// permission on platform
if ok, err := s.IsSudo(ctx, currentUser.ID, schema.PlatformCheckPermission); err != nil {
// Only act (and audit) when the specific relation actually exists, so the
// revoke event reflects a real state change. Checking the relation directly
// is precise for both admin and member.
present, err := s.IsSudo(ctx, currentUser.ID, relationName)
if err != nil {
return err
} else if !ok {
// not needed
}
if !present {
return nil
}

// unmark su
err = s.relationService.Delete(ctx, relation.Relation{
if err := s.relationService.Delete(ctx, relation.Relation{
Object: relation.Object{
ID: schema.PlatformID,
Namespace: schema.PlatformNamespace,
Expand All @@ -499,6 +525,36 @@ func (s Service) UnSudo(ctx context.Context, id string) error {
Namespace: schema.ServiceUserPrincipal,
},
RelationName: relationName,
}); err != nil {
return err
}

event := pkgAuditRecord.PlatformAdminRemovedEvent
if relationName == schema.MemberRelationName {
event = pkgAuditRecord.PlatformMemberRemovedEvent
}
return s.recordPlatformAuditRecord(ctx, currentUser, event, relationName)
}

// recordPlatformAuditRecord writes an audit record for a platform relation
// (admin or member) grant/revoke on a service user. Actor is left empty so the
// repository enriches it from context (acting principal, or system actor at boot).
func (s Service) recordPlatformAuditRecord(ctx context.Context, su ServiceUser, event pkgAuditRecord.Event, relationName string) error {
_, err := s.auditRecordRepository.Create(ctx, models.AuditRecord{
Event: event,
Resource: models.Resource{
ID: schema.PlatformID,
Type: pkgAuditRecord.PlatformType,
Name: schema.PlatformID,
},
Target: &models.Target{
ID: su.ID,
Type: pkgAuditRecord.ServiceUserType,
Name: su.Title,
},
OrgID: schema.PlatformOrgID.String(),
OccurredAt: time.Now().UTC(),
Metadata: map[string]any{"relation": relationName},
})
return err
}
Loading
Loading