Skip to content
Merged
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
76 changes: 76 additions & 0 deletions cmd/reconcile.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
package cmd

import (
"fmt"
"os"

"github.com/MakeNowJust/heredoc"
"github.com/raystack/frontier/internal/reconcile"
cli "github.com/spf13/cobra"
)

func ReconcileCommand(cliConfig *Config) *cli.Command {
var (
filePath string
dryRun bool
header string
)
cmd := &cli.Command{
Use: "reconcile",
Short: "Reconcile platform configuration to a desired-state file",
Long: heredoc.Doc(`
Make platform resources match a desired-state YAML file, through the admin API.

Supports the PlatformUser kind (platform admins and members) for now. The file
decides who has access: anyone listed is added, anyone not listed is removed.
Log in as a superuser (for example the bootstrap service account) with --header.
`),
Example: heredoc.Doc(`
$ frontier reconcile -f platform-users.yaml --dry-run -H "Authorization:Basic <base64>"
$ frontier reconcile -f platform-users.yaml -H "Authorization:Basic <base64>"
`),
Annotations: map[string]string{
"group": "core",
"client": "true",
},
RunE: func(cmd *cli.Command, args []string) error {
data, err := os.ReadFile(filePath)
if err != nil {
return fmt.Errorf("read desired-state file: %w", err)
}
adminClient, err := createAdminClient(cliConfig.Host)
if err != nil {
return err
}
registry := map[string]reconcile.Reconciler{
reconcile.KindPlatformUser: reconcile.NewPlatformUserReconciler(adminClient, header),
}
reports, runErr := reconcile.Run(cmd.Context(), registry, data, dryRun)
for _, rep := range reports {
printReconcileReport(cmd, rep)
}
return runErr
},
}
cmd.Flags().StringVarP(&filePath, "file", "f", "", "Path to the desired-state YAML file")
cmd.MarkFlagRequired("file")
cmd.Flags().BoolVar(&dryRun, "dry-run", false, "Print the plan without applying changes")
cmd.Flags().StringVarP(&header, "header", "H", "", "Header <key>:<value> for auth, e.g. 'Authorization:Basic <base64>'")
bindFlagsFromClientConfig(cmd)
return cmd
}

func printReconcileReport(cmd *cli.Command, rep reconcile.Report) {
if len(rep.Planned) == 0 {
cmd.Printf("%s: no changes\n", rep.Kind)
return
}
verb, count := "applied", rep.Applied
if rep.DryRun {
verb, count = "planned", len(rep.Planned)
}
cmd.Printf("%s (%s %d):\n", rep.Kind, verb, count)
for _, p := range rep.Planned {
cmd.Printf(" - %s\n", p)
}
}
1 change: 1 addition & 0 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ func New(cliConfig *Config) *cli.Command {
cmd.AddCommand(PermissionCommand(cliConfig))
cmd.AddCommand(PolicyCommand(cliConfig))
cmd.AddCommand(SeedCommand(cliConfig))
cmd.AddCommand(ReconcileCommand(cliConfig))
cmd.AddCommand(configCommand())
cmd.AddCommand(versionCommand())
cmd.AddCommand(PreferencesCommand(cliConfig))
Expand Down
9 changes: 6 additions & 3 deletions cmd/serve.go
Original file line number Diff line number Diff line change
Expand Up @@ -212,8 +212,9 @@ func StartServer(logger *slog.Logger, cfg *config.Frontier) error {
if err = deps.BootstrapService.MigrateRoles(ctx); err != nil {
return err
}
// promote normal users to superusers
if err = deps.BootstrapService.MakeSuperUsers(ctx); err != nil {
// ensure the config-seeded bootstrap superuser service account (for automation/GitOps).
// all other platform-user management is handled out-of-band via the GitOps reconcile flow.
if err = deps.BootstrapService.EnsureBootstrapSuperUser(ctx); err != nil {
return err
}

Expand Down Expand Up @@ -569,14 +570,16 @@ func buildAPIDependencies(
namespaceService,
roleService,
permissionService,
userService,
authzSchemaRepository,
relationService,
policyService,
svUserRepo,
cfg.App.PAT.DeniedPermissionsSet(),
planService,
planBlobRepository,
svUserRepo,
scUserCredRepo,
serviceUserService,
)

cascadeDeleter := deleter.NewCascadeDeleter(organizationService, projectService, resourceService,
Expand Down
20 changes: 14 additions & 6 deletions config/sample.config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -179,12 +179,20 @@ app:

# platform level administration
admin:
# Email list of users which needs to be converted as superusers
# if the user is already present in the system, it is promoted to su
# if not, a new account is created with provided email id and promoted to su.
# 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 always has
# a superuser to log in as. This means you don't need an existing superuser to create
# the first one. The account is created and promoted to superuser on every boot (safe
# to run again), and the secret is rotated if you change it here. Log in with:
# Authorization: Basic base64(client_id:client_secret).
# Leave client_id/client_secret empty to turn this off — but then no superuser is
# seeded, and since granting one needs a superuser, none can be created via the API
# either. client_id must be a UUID (it is the service-account credential id); generate
# one (e.g. uuidgen) and keep it stable. client_secret is your chosen password.
bootstrap:
Comment thread
rohilsurana marked this conversation as resolved.
client_id: ""
client_secret: ""
# title: "GitOps Bootstrap Superuser"
# smtp configuration for sending emails
mailer:
smtp_host: smtp.example.com
Expand Down
3 changes: 3 additions & 0 deletions core/serviceuser/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,7 @@ var (
ErrConflict = errors.New("service user already exist")
ErrEmptyKey = errors.New("empty key")
ErrDisabled = errors.New("service user is disabled")
// ErrProtected marks a server-managed service user (the config-bootstrapped
// bootstrap SA) whose credentials must not be mutated via the API.
ErrProtected = errors.New("service user is protected")
)
8 changes: 8 additions & 0 deletions core/serviceuser/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,14 @@ func (s Service) GetKey(ctx context.Context, credID string) (Credential, error)
}

func (s Service) DeleteKey(ctx context.Context, credID string) error {
// Never let a credential/key/token of the config-bootstrapped bootstrap SA be
// deleted via the API — it is server-managed (rotated at boot). Boot rotation
// deletes via the repository directly, so it is unaffected by this guard.
if cred, err := s.credRepo.Get(ctx, credID); err == nil && cred.ServiceUserID == schema.BootstrapServiceUserID {
s.log.WarnContext(ctx, "refused deleting a credential of the bootstrap superuser service account",
"serviceuser_id", cred.ServiceUserID, "credential_id", credID)
return ErrProtected
}
return s.credRepo.Delete(ctx, credID)
}

Expand Down
28 changes: 28 additions & 0 deletions core/serviceuser/service_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -345,3 +345,31 @@ func TestService_ListByOrg(t *testing.T) {
})
}
}

func TestService_DeleteKey_BootstrapProtected(t *testing.T) {
ctx := context.Background()
const credID = "cred-1"

t.Run("refuses deleting a credential owned by the bootstrap SA", func(t *testing.T) {
svc, _, credRepo, _, _, _ := newTestService(t)
credRepo.On("Get", ctx, credID).Return(
serviceuser.Credential{ID: credID, ServiceUserID: schema.BootstrapServiceUserID}, nil)

err := svc.DeleteSecret(ctx, credID) // funnels through DeleteKey
if !errors.Is(err, serviceuser.ErrProtected) {
t.Fatalf("want ErrProtected, got %v", err)
}
credRepo.AssertNotCalled(t, "Delete", mock.Anything, mock.Anything)
})

t.Run("deletes a credential of a normal service user", func(t *testing.T) {
svc, _, credRepo, _, _, _ := newTestService(t)
credRepo.On("Get", ctx, credID).Return(
serviceuser.Credential{ID: credID, ServiceUserID: "11111111-2222-3333-4444-555555555555"}, nil)
credRepo.On("Delete", ctx, credID).Return(nil)

if err := svc.DeleteToken(ctx, credID); err != nil { // also funnels through DeleteKey
t.Fatalf("unexpected error: %v", err)
}
})
}
8 changes: 8 additions & 0 deletions internal/api/v1beta1connect/platform.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package v1beta1connect
import (
"context"
"fmt"
"log/slog"
"sort"
"strings"

Expand Down Expand Up @@ -56,6 +57,13 @@ func (h *ConnectHandler) RemovePlatformUser(ctx context.Context, req *connect.Re
}
}
} else if req.Msg.GetServiceuserId() != "" {
// The server manages the bootstrap SA (it seeds it at boot), so it can't be
// removed through this API. Return the generic permission error and log why.
// The response must not reveal that this id is the protected SA.
if req.Msg.GetServiceuserId() == schema.BootstrapServiceUserID {
slog.WarnContext(ctx, "refused removal of the bootstrap superuser service account", "service_user_id", req.Msg.GetServiceuserId())
return nil, connect.NewError(connect.CodePermissionDenied, ErrUnauthorized)
}
for _, relationName := range platformRelations {
if err := h.serviceUserService.UnSudo(ctx, req.Msg.GetServiceuserId(), relationName); err != nil {
return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("RemovePlatformUser.ServiceUserUnSudo: service_user_id=%s relation=%s: %w", req.Msg.GetServiceuserId(), relationName, err))
Expand Down
15 changes: 15 additions & 0 deletions internal/api/v1beta1connect/platform_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,21 @@ func TestHandler_RemovePlatformUser(t *testing.T) {
assert.NotNil(t, resp)
})

t.Run("refuses to remove the bootstrap superuser service account", func(t *testing.T) {
serviceUserSvc := mocks.NewServiceUserService(t)
// the target is the well-known bootstrap SA -> reject before any UnSudo.
h := &ConnectHandler{serviceUserService: serviceUserSvc}
resp, err := h.RemovePlatformUser(context.Background(), connect.NewRequest(&frontierv1beta1.RemovePlatformUserRequest{
ServiceuserId: schema.BootstrapServiceUserID,
}))
assert.Error(t, err)
assert.Nil(t, resp)
assert.Equal(t, connect.CodePermissionDenied, connect.CodeOf(err))
// must not reveal that this id is the protected bootstrap SA.
assert.NotContains(t, err.Error(), "bootstrap")
serviceUserSvc.AssertNotCalled(t, "UnSudo", mock.Anything, mock.Anything, mock.Anything)
})

t.Run("removes only the specified relation when relation is set", func(t *testing.T) {
userSvc := mocks.NewUserService(t)
// only the admin relation is stripped; an UnSudo for member would be an
Expand Down
41 changes: 41 additions & 0 deletions internal/api/v1beta1connect/serviceuser.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"fmt"
"log/slog"

"connectrpc.com/connect"
"github.com/lestrrat-go/jwx/v2/jwk"
Expand Down Expand Up @@ -168,11 +169,31 @@ func (h *ConnectHandler) CreateServiceUser(ctx context.Context, request *connect
}), nil
}

// errBootstrapSAImmutable returns a PermissionDenied error when id is the bootstrap
// SA (its fixed id). That account is managed only through app.admin.bootstrap at
// boot. The API must never delete it or create credentials, keys, or tokens for it.
// Doing so would leave a superuser way in that config can't rotate away. Not even
// platform superusers are allowed.
//
// The response is the generic permission error, so it doesn't reveal that this id is
// the protected SA. The specific reason is only logged.
func errBootstrapSAImmutable(ctx context.Context, id string) error {
Comment thread
rohilsurana marked this conversation as resolved.
if id == schema.BootstrapServiceUserID {
slog.WarnContext(ctx, "refused API mutation of the bootstrap superuser service account", "service_user_id", id)
return connect.NewError(connect.CodePermissionDenied, ErrUnauthorized)
}
return nil
}

func (h *ConnectHandler) DeleteServiceUser(ctx context.Context, request *connect.Request[frontierv1beta1.DeleteServiceUserRequest]) (*connect.Response[frontierv1beta1.DeleteServiceUserResponse], error) {
errorLogger := NewErrorLogger()
serviceUserID := request.Msg.GetId()
orgID := request.Msg.GetOrgId()

if err := errBootstrapSAImmutable(ctx, serviceUserID); err != nil {
return nil, err
}

err := h.serviceUserService.Delete(ctx, serviceUserID)
if err != nil {
errorLogger.LogServiceError(ctx, request, "DeleteServiceUser", err,
Expand All @@ -198,6 +219,10 @@ func (h *ConnectHandler) CreateServiceUserJWK(ctx context.Context, request *conn
serviceUserID := request.Msg.GetId()
title := request.Msg.GetTitle()

if err := errBootstrapSAImmutable(ctx, serviceUserID); err != nil {
return nil, err
}

svCred, err := h.serviceUserService.CreateKey(ctx, serviceuser.Credential{
ServiceUserID: serviceUserID,
Title: title,
Expand Down Expand Up @@ -300,6 +325,8 @@ func (h *ConnectHandler) DeleteServiceUserJWK(ctx context.Context, request *conn
switch {
case err == serviceuser.ErrCredNotExist:
return nil, connect.NewError(connect.CodeNotFound, ErrServiceUserCredNotFound)
case errors.Is(err, serviceuser.ErrProtected):
return nil, connect.NewError(connect.CodePermissionDenied, ErrUnauthorized)
default:
return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("DeleteServiceUserJWK: key_id=%s: %w", keyID, err))
}
Expand All @@ -312,6 +339,10 @@ func (h *ConnectHandler) CreateServiceUserCredential(ctx context.Context, reques
serviceUserID := request.Msg.GetId()
title := request.Msg.GetTitle()

if err := errBootstrapSAImmutable(ctx, serviceUserID); err != nil {
return nil, err
}

secret, err := h.serviceUserService.CreateSecret(ctx, serviceuser.Credential{
ServiceUserID: serviceUserID,
Title: title,
Expand Down Expand Up @@ -355,6 +386,9 @@ func (h *ConnectHandler) DeleteServiceUserCredential(ctx context.Context, reques

err := h.serviceUserService.DeleteSecret(ctx, secretID)
if err != nil {
if errors.Is(err, serviceuser.ErrProtected) {
return nil, connect.NewError(connect.CodePermissionDenied, ErrUnauthorized)
}
return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("DeleteServiceUserCredential: secret_id=%s: %w", secretID, err))
}
return connect.NewResponse(&frontierv1beta1.DeleteServiceUserCredentialResponse{}), nil
Expand All @@ -364,6 +398,10 @@ func (h *ConnectHandler) CreateServiceUserToken(ctx context.Context, request *co
serviceUserID := request.Msg.GetId()
title := request.Msg.GetTitle()

if err := errBootstrapSAImmutable(ctx, serviceUserID); err != nil {
return nil, err
}

secret, err := h.serviceUserService.CreateToken(ctx, serviceuser.Credential{
ServiceUserID: serviceUserID,
Title: title,
Expand Down Expand Up @@ -406,6 +444,9 @@ func (h *ConnectHandler) DeleteServiceUserToken(ctx context.Context, request *co

err := h.serviceUserService.DeleteToken(ctx, tokenID)
if err != nil {
if errors.Is(err, serviceuser.ErrProtected) {
return nil, connect.NewError(connect.CodePermissionDenied, ErrUnauthorized)
}
return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("DeleteServiceUserToken: token_id=%s: %w", tokenID, err))
}
return connect.NewResponse(&frontierv1beta1.DeleteServiceUserTokenResponse{}), nil
Expand Down
Loading
Loading