Skip to content
Closed
Show file tree
Hide file tree
Changes from 6 commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
e175a9a
feat: add opt-in authoritative superuser reconciliation
rohilsurana Jun 21, 2026
5a346d7
feat: audit platform member grants and revokes alongside admin
rohilsurana Jun 22, 2026
fc476a5
refactor: rename platform audit events to added/removed
rohilsurana Jun 22, 2026
1625cf6
fix(bootstrap): abort superuser reconciliation on non-not-found resol…
rohilsurana Jun 22, 2026
b059826
feat: drop boot-time authoritative reconciliation in favor of gitops
rohilsurana Jun 29, 2026
b3ca1b6
feat(bootstrap): seed superuser service account from config
rohilsurana Jun 29, 2026
b9fef93
test(bootstrap): cover additive MakeSuperUsers promotion
rohilsurana Jun 29, 2026
dc9e0b8
fix(serviceuser): tolerate null org_name for platform-level service u…
rohilsurana Jun 29, 2026
8550d37
fix(bootstrap): require a uuid client_id for the bootstrap superuser
rohilsurana Jun 29, 2026
75a44b2
feat: drop config human-superuser flow; bootstrap service account is …
rohilsurana Jun 29, 2026
eeb1e7b
feat(cli): add declarative reconcile framework + platform-user reconc…
rohilsurana Jun 29, 2026
fada450
chore(proto): bump proton pin to ee05c27 and regenerate (RemovePlatfo…
rohilsurana Jun 29, 2026
01c8ac2
feat: relation-selective platform-user removal (handler + reconciler)
rohilsurana Jun 29, 2026
f8c70da
fix(bootstrap): run superuser bootstrap test at min bcrypt cost to av…
rohilsurana Jun 30, 2026
5d867f6
test(bootstrap): capture credential and assert hash after call instea…
rohilsurana Jun 30, 2026
241ec94
test(bootstrap): clarify why bootstrap test runs at min bcrypt cost
rohilsurana Jun 30, 2026
8ba911f
fix(bootstrap): roll back orphan service user when credential creatio…
rohilsurana Jun 30, 2026
5c49f50
fix(platform): make Sudo idempotency relation-level so admin to membe…
rohilsurana Jun 30, 2026
b43ee01
fix(platform): list all relations per principal so reconcile converge…
rohilsurana Jun 30, 2026
dd7937f
feat(platform): protect bootstrap superuser service account from remo…
rohilsurana Jun 30, 2026
f90dd18
fix(platform): make bootstrap flag server-authoritative so SAs cannot…
rohilsurana Jun 30, 2026
e566ad3
refactor(platform): pin bootstrap SA to a well-known id and protect i…
rohilsurana Jun 30, 2026
44ad253
feat(platform): refuse deletion of the bootstrap superuser service ac…
rohilsurana Jun 30, 2026
f493e92
feat(platform): refuse minting credentials/keys/tokens for the bootst…
rohilsurana Jun 30, 2026
2aa9a73
refactor(platform): inline user audit event selection to match servic…
rohilsurana Jun 30, 2026
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
11 changes: 9 additions & 2 deletions cmd/serve.go
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,10 @@ func StartServer(logger *slog.Logger, cfg *config.Frontier) error {
if err = deps.BootstrapService.MakeSuperUsers(ctx); err != nil {
return err
}
// ensure the config-seeded bootstrap superuser service account (for automation/GitOps)
if err = deps.BootstrapService.EnsureBootstrapSuperUser(ctx); err != nil {
return err
}

// Backfill legacy SU org policies. Non-fatal: this runs over unbounded
// customer data and a single stuck row shouldn't brick the server.
Expand Down Expand Up @@ -395,7 +399,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 +436,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 Expand Up @@ -577,6 +581,9 @@ func buildAPIDependencies(
cfg.App.PAT.DeniedPermissionsSet(),
planService,
planBlobRepository,
svUserRepo,
scUserCredRepo,
serviceUserService,
)

cascadeDeleter := deleter.NewCascadeDeleter(organizationService, projectService, resourceService,
Expand Down
10 changes: 10 additions & 0 deletions config/sample.config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,16 @@ 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: []
# bootstrap seeds a superuser SERVICE ACCOUNT from config (a username/password-style
# client_id + client_secret) so automation like the GitOps reconcile flow has a
# guaranteed superuser identity without a chicken-and-egg. The account is ensured and
# promoted to superuser on every boot (idempotent); the secret is rotated if it changes
# here. Authenticate with: Authorization: Basic base64(client_id:client_secret).
# Leave client_id/client_secret empty to disable.
bootstrap:
client_id: ""
client_secret: ""
# title: "GitOps Bootstrap Superuser"
# 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