Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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
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
8 changes: 8 additions & 0 deletions config/sample.config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,14 @@ app:
# UUIDs/slugs of existing users can also be provided instead of email ids
# but in that case a new user will not be created.
users: []
# authoritative makes the `users` list above the single source of truth for
# human superusers. When true, at every startup any human holding the platform
# admin (superuser) relation that is NOT in `users` is demoted. Service accounts
# are never affected by reconciliation.
# WARNING: with an empty or misconfigured `users` list this demotes ALL human
# superusers. Recovery is to fix `users` and restart (or rely on a service-account
# superuser). Defaults to false (additive only — the historical behavior).
authoritative: false
# smtp configuration for sending emails
mailer:
smtp_host: smtp.example.com
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.

98 changes: 77 additions & 21 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 @@ -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)
}

// 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