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
3 changes: 1 addition & 2 deletions cmd/export.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,11 +31,10 @@ func ExportCommand(cliConfig *Config) *cli.Command {
},
Args: cli.ExactArgs(1),
RunE: func(cmd *cli.Command, args []string) error {
adminClient, err := createAdminClient(cliConfig.Host)
registry, err := buildReconcileRegistry(cliConfig.Host, header)
if err != nil {
return err
}
registry := reconcileRegistry(adminClient, header)
kind, err := resolveKind(args[0], registry)
if err != nil {
return err
Expand Down
37 changes: 29 additions & 8 deletions cmd/reconcile.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,11 @@ func ReconcileCommand(cliConfig *Config) *cli.Command {
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.
Kinds: PlatformUser (platform admins and members), Permission (custom
permissions), and Role (platform-level roles). Deleting a permission or a
role needs an explicit 'delete: true' on its entry; nothing is deleted by
omission. Log in as a superuser (for example the bootstrap service account)
with --header.

Use "frontier export <kind>" to print the current state in this file format.
`),
Expand All @@ -41,11 +43,11 @@ func ReconcileCommand(cliConfig *Config) *cli.Command {
if err != nil {
return fmt.Errorf("read desired-state file: %w", err)
}
adminClient, err := createAdminClient(cliConfig.Host)
registry, err := buildReconcileRegistry(cliConfig.Host, header)
if err != nil {
return err
}
reports, runErr := reconcile.Run(cmd.Context(), reconcileRegistry(adminClient, header), data, dryRun)
reports, runErr := reconcile.Run(cmd.Context(), registry, data, dryRun)
for _, rep := range reports {
printReconcileReport(cmd, rep)
}
Expand All @@ -60,11 +62,30 @@ func ReconcileCommand(cliConfig *Config) *cli.Command {
return cmd
}

// reconcileRegistry holds every reconcilable kind. New kinds register here.
func reconcileRegistry(adminClient frontierv1beta1connect.AdminServiceClient, header string) map[string]reconcile.Reconciler {
// reconcileAPI joins the two generated clients: reads live on FrontierService,
// writes on AdminService. The embedded method sets combine to satisfy the
// per-kind API interfaces.
type reconcileAPI struct {
frontierv1beta1connect.AdminServiceClient
frontierv1beta1connect.FrontierServiceClient
}

// buildReconcileRegistry holds every reconcilable kind. New kinds register here.
func buildReconcileRegistry(host, header string) (map[string]reconcile.Reconciler, error) {
adminClient, err := createAdminClient(host)
if err != nil {
return nil, err
}
frontierClient, err := createClient(host)
if err != nil {
return nil, err
}
api := reconcileAPI{AdminServiceClient: adminClient, FrontierServiceClient: frontierClient}
return map[string]reconcile.Reconciler{
reconcile.KindPlatformUser: reconcile.NewPlatformUserReconciler(adminClient, header),
}
reconcile.KindPermission: reconcile.NewPermissionReconciler(api, header),
reconcile.KindRole: reconcile.NewRoleReconciler(api, header),
}, nil
}

// printReconcileReport writes the report to stdout. cobra's cmd.Printf falls
Expand Down
10 changes: 6 additions & 4 deletions docs/content/docs/reference/cli.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ List of supported environment variables

Export the current state of a kind as a desired-state YAML file, printed to
stdout. The output is the format `frontier reconcile` reads: reconciling it
changes nothing. Supported kind: `PlatformUser`.
changes nothing. Supported kinds: `PlatformUser`, `Permission`, `Role`.

```
$ frontier export platformuser -H "Authorization:Basic <base64>" > platform-users.yaml
Expand Down Expand Up @@ -245,9 +245,11 @@ View a project
## `frontier reconcile [flags]`

Make platform resources match a desired-state YAML file, through the admin
API. The file decides who has access: anyone listed is added, anyone not
listed is removed. Supported kind: `PlatformUser`. Use `frontier export` to
print the current state in this file format.
API. Supported kinds: `PlatformUser` (anyone listed is added, anyone not
listed is removed), `Permission` (custom permissions), and `Role`
(platform-level roles). Deleting a permission or a role needs an explicit
`delete: true` on its entry; nothing is deleted by omission. Use
`frontier export` to print the current state in this file format.

```
$ frontier reconcile -f platform-users.yaml --dry-run -H "Authorization:Basic <base64>"
Expand Down
58 changes: 7 additions & 51 deletions internal/bootstrap/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,6 @@ type RoleService interface {
Get(ctx context.Context, id string) (role.Role, error)
List(ctx context.Context, f role.Filter) ([]role.Role, error)
Upsert(ctx context.Context, toCreate role.Role) (role.Role, error)
Update(ctx context.Context, toUpdate role.Role) (role.Role, error)
}

type RelationService interface {
Expand Down Expand Up @@ -288,20 +287,21 @@ func (s Service) MigrateRoles(ctx context.Context) error {
return nil
}

// migrateRole makes the stored role match its definition: create when missing,
// reconcile when present. Roles are keyed by name, so renaming a definition
// produces a new role rather than renaming the existing one.
// migrateRole creates the role when it is missing and leaves it alone when it
// exists. Operators manage existing roles (title, permissions) out of band via
// the reconcile flow; updating them here would revert those changes on every
// boot.
func (s Service) migrateRole(ctx context.Context, orgID string, defRole schema.RoleDefinition) error {
existing, err := s.roleService.Get(ctx, defRole.Name)
_, err := s.roleService.Get(ctx, defRole.Name)
if errors.Is(err, role.ErrNotExist) {
return s.createRole(ctx, orgID, defRole)
}
if err != nil {
// A transient Get failure must not fall through to create: that would
// re-Upsert an existing role and skip the prune, the very drift this fixes.
// re-Upsert an existing role, reverting out-of-band changes.
return fmt.Errorf("get role %s: %w: %s", defRole.Name, schema.ErrMigration, err.Error())
}
return s.reconcileRole(ctx, existing, defRole)
return nil
}

func (s Service) createRole(ctx context.Context, orgID string, defRole schema.RoleDefinition) error {
Expand All @@ -322,50 +322,6 @@ func (s Service) createRole(ctx context.Context, orgID string, defRole schema.Ro
return nil
}

// reconcileRole writes the definition onto an existing role only when its
// permission set differs. The write goes through role.Update (not a raw row
// update) because that is what prunes the SpiceDB permission tuples a narrowed
// definition no longer grants; the equality short-circuit keeps every boot from
// rewriting unchanged roles and emitting a spurious RoleUpdated audit record.
func (s Service) reconcileRole(ctx context.Context, existing role.Role, defRole schema.RoleDefinition) error {
if permissionsEqual(existing.Permissions, defRole.Permissions) {
return nil
}
existing.Title = defRole.Title
existing.Permissions = defRole.Permissions
existing.Scopes = defRole.Scopes
if existing.Metadata == nil {
existing.Metadata = map[string]any{}
}
existing.Metadata["description"] = defRole.Description
if _, err := s.roleService.Update(ctx, existing); err != nil {
return fmt.Errorf("can't reconcile role: %w: %s", schema.ErrMigration, err.Error())
}
return nil
}

// permissionsEqual reports whether two permission slug sets are equal, ignoring
// order and duplicates.
func permissionsEqual(a, b []string) bool {
setA := make(map[string]struct{}, len(a))
for _, p := range a {
setA[p] = struct{}{}
}
setB := make(map[string]struct{}, len(b))
for _, p := range b {
setB[p] = struct{}{}
}
if len(setA) != len(setB) {
return false
}
for p := range setB {
if _, ok := setA[p]; !ok {
return false
}
}
return true
}

// MigrateServiceUserOrgPolicies backfills the org policy for service users that
// have only a SpiceDB member relation (legacy creation flow). Idempotent: on a
// clean cluster the candidate query returns zero rows and this is a no-op.
Expand Down
58 changes: 7 additions & 51 deletions internal/bootstrap/service_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -261,13 +261,15 @@ func Test_migrateRole(t *testing.T) {
roleSvc.AssertNotCalled(t, "Update")
})

t.Run("skips reconcile when the permission set is unchanged", func(t *testing.T) {
t.Run("leaves an existing role alone even when it differs from the definition", func(t *testing.T) {
roleSvc := new(mockRoleService)
roleSvc.On("Get", mock.Anything, def.Name).Return(role.Role{
ID: "role-1",
Name: def.Name,
// same set, different order -> still equal
Permissions: []string{"app_organization_update", "app_organization_get"},
ID: "role-1",
Name: def.Name,
Title: "Renamed By Operator",
// differs from the definition; operators own existing roles, so
// boot must not write it back
Permissions: []string{"app_organization_get"},
}, nil)

svc := Service{roleService: roleSvc}
Expand All @@ -276,24 +278,6 @@ func Test_migrateRole(t *testing.T) {
roleSvc.AssertNotCalled(t, "Update")
})

t.Run("reconciles a drifted (over-granting) role to the definition", func(t *testing.T) {
roleSvc := new(mockRoleService)
roleSvc.On("Get", mock.Anything, def.Name).Return(role.Role{
ID: "role-1",
Name: def.Name,
// has an extra permission no longer in the definition
Permissions: []string{"app_organization_get", "app_organization_update", "app_organization_delete"},
}, nil)
roleSvc.On("Update", mock.Anything, mock.MatchedBy(func(r role.Role) bool {
return r.ID == "role-1" && len(r.Permissions) == 2 &&
!contains(r.Permissions, "app_organization_delete")
})).Return(role.Role{ID: "role-1"}, nil)

svc := Service{roleService: roleSvc}
assert.NoError(t, svc.migrateRole(context.Background(), "org-1", def))
roleSvc.AssertNotCalled(t, "Upsert")
})

t.Run("propagates a transient Get error instead of creating", func(t *testing.T) {
roleSvc := new(mockRoleService)
// a non-ErrNotExist failure must not fall through to Upsert/Update
Expand All @@ -306,31 +290,3 @@ func Test_migrateRole(t *testing.T) {
roleSvc.AssertNotCalled(t, "Update")
})
}

func contains(s []string, v string) bool {
for _, e := range s {
if e == v {
return true
}
}
return false
}

func Test_permissionsEqual(t *testing.T) {
cases := []struct {
name string
a, b []string
want bool
}{
{"equal ignoring order", []string{"x", "y"}, []string{"y", "x"}, true},
{"equal ignoring duplicates", []string{"x", "x"}, []string{"x"}, true},
{"different members", []string{"x", "y"}, []string{"x", "z"}, false},
{"superset", []string{"x"}, []string{"x", "y"}, false},
{"both empty", nil, []string{}, true},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
assert.Equal(t, c.want, permissionsEqual(c.a, c.b))
})
}
}
121 changes: 121 additions & 0 deletions internal/reconcile/permission.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
package reconcile

import (
"fmt"
"sort"
"strings"

"github.com/raystack/frontier/internal/bootstrap/schema"
)

// KindPermission is the desired-state document kind for custom permissions.
const KindPermission = "Permission"

// PermissionSpec is one desired permission. A permission is identity only
// (namespace + name): it is added or deleted, never updated. Deleting needs
// the explicit flag; a permission that just disappears from the file fails
// the plan instead.
type PermissionSpec struct {
Namespace string `yaml:"namespace"`
Name string `yaml:"name"`
Delete bool `yaml:"delete,omitempty"`
}

func (s PermissionSpec) String() string {
return s.Namespace + ":" + s.Name
}

func (s PermissionSpec) slug() string {
return schema.FQPermissionNameFromNamespace(s.Namespace, s.Name)
}

// isBaseNamespace reports whether a namespace belongs to the base schema,
// which the server manages itself.
func isBaseNamespace(ns string) bool {
return ns == "app" || strings.HasPrefix(ns, "app/")
}

func validatePermissionSpec(s PermissionSpec) error {
if strings.TrimSpace(s.Namespace) == "" || strings.TrimSpace(s.Name) == "" {
return fmt.Errorf("namespace and name are required")
}
if !schema.IsValidPermissionName(s.Name) {
return fmt.Errorf("invalid name %q (alphanumeric only)", s.Name)
}
if isBaseNamespace(s.Namespace) {
return fmt.Errorf("namespace %q is part of the base schema, which the server manages", s.Namespace)
}
if parts := strings.Split(s.Namespace, "/"); len(parts) != 2 || parts[0] == "" || parts[1] == "" {
return fmt.Errorf("namespace %q must be in service/resource form", s.Namespace)
}
return nil
}

// currentPermission is one custom permission as returned by ListPermissions.
type currentPermission struct {
ID string
Namespace string
Name string
}

type permissionOp struct {
action opAction
spec PermissionSpec
id string // server row id, set for deletes
}

func (o permissionOp) String() string {
if o.action == opRemove {
return fmt.Sprintf("delete permission %s", o.spec)
}
return fmt.Sprintf("add permission %s", o.spec)
}

// diffPermissions returns the ops that make the current custom permissions
// match the desired spec. Every custom permission on the server must appear in
// the spec — kept, or marked delete — so nothing is ever removed by omission.
func diffPermissions(desired []PermissionSpec, current []currentPermission) ([]permissionOp, error) {
bySlug := make(map[string]currentPermission, len(current))
for _, c := range current {
bySlug[schema.FQPermissionNameFromNamespace(c.Namespace, c.Name)] = c
}

seenDelete := map[string]bool{}
accounted := map[string]struct{}{}
var adds, removes []permissionOp
for _, s := range desired {
if err := validatePermissionSpec(s); err != nil {
return nil, fmt.Errorf("invalid permission spec %s: %w", s, err)
}
slug := s.slug()
if prev, dup := seenDelete[slug]; dup {
if prev != s.Delete {
return nil, fmt.Errorf("permission %s is listed both with and without delete", s)
}
continue
}
seenDelete[slug] = s.Delete
accounted[slug] = struct{}{}

cur, exists := bySlug[slug]
switch {
case s.Delete && exists:
removes = append(removes, permissionOp{action: opRemove, spec: s, id: cur.ID})
case !s.Delete && !exists:
adds = append(adds, permissionOp{action: opAdd, spec: s})
}
}

var unaccounted []string
for slug, c := range bySlug {
if _, ok := accounted[slug]; !ok {
unaccounted = append(unaccounted, c.Namespace+":"+c.Name)
}
}
if len(unaccounted) > 0 {
sort.Strings(unaccounted)
return nil, fmt.Errorf("permissions exist on the server but are not in the file: %s; keep them or mark them 'delete: true'", strings.Join(unaccounted, ", "))
}

return append(adds, removes...), nil
}
Loading
Loading