Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
79ed7c6
feat(reconcile): add Permission and Role kinds, bootstrap only create…
rohilsurana Jul 3, 2026
cc54a29
merge feat/reconcile-export: stdout fix
rohilsurana Jul 3, 2026
42c139c
merge feat/reconcile-export: skip empty report
rohilsurana Jul 3, 2026
587d09c
merge feat/reconcile-export: Export folded into Reconciler contract
rohilsurana Jul 14, 2026
2cb94ca
fix(reconcile): export predefined-role scope overrides, reject manage…
rohilsurana Jul 14, 2026
d555799
fix(reconcile): fail on permission entries whose identities collide o…
rohilsurana Jul 14, 2026
f4b5658
merge feat/reconcile-export: apiVersion and whole-file preflight
rohilsurana Jul 14, 2026
26563e4
feat(config): log a deprecation warning when resources_config_path is…
rohilsurana Jul 14, 2026
f40370a
chore(config): drop rfc path from the deprecation log line
rohilsurana Jul 14, 2026
d971883
merge feat/reconcile-export: reconcile docs guide
rohilsurana Jul 14, 2026
f2a5c1c
test(reconcile): pin apply-time failure when a role references a miss…
rohilsurana Jul 14, 2026
e724e9b
feat(reconcile): fail fast when a Role document precedes its Permissi…
rohilsurana Jul 15, 2026
0363a65
fix(reconcile): add missing context import in role tests
rohilsurana Jul 15, 2026
44e365a
feat(reconcile): converge predefined roles to their shipped definitio…
rohilsurana Jul 15, 2026
bff42ac
fix(role): reject creating a platform role with no permissions
rohilsurana Jul 15, 2026
2ec0b55
docs(reconcile): document the Permission and Role kinds and the permi…
rohilsurana Jul 15, 2026
24fc116
feat(reconcile): manage role description as a spec field for custom a…
rohilsurana Jul 15, 2026
71e95c2
docs(reconcile): show the role description field in the Role example
rohilsurana Jul 15, 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
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
4 changes: 4 additions & 0 deletions cmd/serve.go
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,10 @@ func StartServer(logger *slog.Logger, cfg *config.Frontier) error {
}()

// load resource config
if cfg.App.ResourcesConfigPath != "" {
logger.Warn("app.resources_config_path is deprecated and will be removed after a deprecation window; " +
"manage permissions and roles with 'frontier reconcile' instead")
}
resourceBlobFS, err := blob.NewStore(ctx, cfg.App.ResourcesConfigPath, cfg.App.ResourcesConfigPathSecret)
if err != nil {
return err
Expand Down
2 changes: 2 additions & 0 deletions config/sample.config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,8 @@ app:
host: "127.0.0.1"
# WARNING: identity_proxy_header bypass all authorization checks and shouldn't be used in production
identity_proxy_header: X-Frontier-Email
# DEPRECATED: will be removed after a deprecation window. Manage permissions
# and roles with 'frontier reconcile' instead (see docs/rfcs/0001-declarative-reconcile.md).
# full path prefixed with scheme where resources config yaml files are kept
# e.g.:
# local storage file "file:///tmp/resources_config"
Expand Down
80 changes: 76 additions & 4 deletions docs/content/docs/reconcile.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,9 @@ Rules that apply to every document:
- The whole file is parsed and checked before anything applies. A malformed later
document stops the run before any change is made.
- Documents apply in the order they appear in the file.
- Some kinds depend on others. A role can grant a permission, so a `Permission` document
must come before a `Role` document in the same file. The wrong order is rejected before
anything applies.

## The PlatformUser kind

Expand All @@ -73,6 +76,75 @@ Two safety properties:

Adding a user by an email that does not exist creates that user.

## The Permission kind

A permission is an identity: a `service/resource` namespace plus a verb. There is nothing
else to manage on it, so it is either created or deleted.

```yaml
apiVersion: v1
kind: Permission
spec:
- namespace: compute/order
name: get
- namespace: compute/order
name: legacy
delete: true
```

- The namespace has two parts, `service/resource`. The name is alphanumeric.
- Frontier's own permissions (the `app` namespaces) belong to the base schema. The
reconciler ignores them and rejects a file entry that names one.
- A custom permission on the server that the file does not list fails the plan. Nothing
is deleted just because it is missing; deleting needs `delete: true` on the entry.
- Creating a permission also updates the authorization schema, so a new permission is
ready to use in roles right away.

## The Role kind

`Role` manages platform-level roles. The role name is the identity and never changes. The
managed fields are the title, the description, the permissions, and the scopes (the
resource types a role can attach to).

```yaml
apiVersion: v1
kind: Role
spec:
- name: compute_order_manager # custom role: must list its permissions
title: Order Manager
description: Manages compute orders
permissions:
- compute_order_get
- compute_order_update
- name: app_project_viewer # predefined role: override only the fields you list
permissions:
- app_project_get
- resource_aoi_get
- name: old_role
delete: true
```

There are two kinds of roles, and they behave differently.

**Custom roles** are yours to manage in full. A custom role must list at least one
permission. Every custom role on the server must appear in the file, kept or marked
`delete: true`; one that is missing fails the plan. A role that still has policy bindings
cannot be deleted, and the apply fails with a clear error.

**Predefined roles** are the ones Frontier ships, like `app_organization_owner` or
`app_project_viewer`. They converge to their shipped definitions. A file entry overrides
only the fields it lists; a field you leave out goes back to the default. A predefined
role you do not list at all resets fully. The plan marks these resets, so a hand-made
change to a predefined role shows up as an update back to the default. You cannot delete a
predefined role; the server recreates it at boot.

Because a predefined role resets to its default, removing its entry from the file is a
real change. If an entry adds permissions to `app_project_viewer`, deleting that entry
takes those permissions away on the next apply.

Permission references accept any form the server knows: the slug (`compute_order_get`),
`service/resource:verb`, or `service.resource.verb`.

## Running it

Log in as a superuser. The bootstrap service user exists for exactly this; its client id
Expand Down Expand Up @@ -115,8 +187,8 @@ The kind argument is case-insensitive and accepts a plural, so `platformuser` an

## More kinds

The design and the rules every kind follows live in
[RFC 0001](https://github.com/raystack/frontier/blob/main/docs/rfcs/0001-declarative-reconcile.md).
Custom permissions and platform-level roles are proposed there as the next kinds; this
page grows with them. The flag reference for both commands is in the
This page covers `PlatformUser`, `Permission`, and `Role`. The design and the rules every
kind follows live in
[RFC 0001](https://github.com/raystack/frontier/blob/main/docs/rfcs/0001-declarative-reconcile.md),
which also lists the kinds proposed next. The flag reference for both commands is in the
[CLI reference](./reference/cli.md).
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`. See the
changes nothing. Supported kinds: `PlatformUser`, `Permission`, `Role`. See the
[Reconcile guide](../reconcile.md) for the file format and the flow.

```
Expand Down Expand Up @@ -246,9 +246,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, and see the
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, and see the
[Reconcile guide](../reconcile.md) for the full flow.

```
Expand Down
3 changes: 3 additions & 0 deletions internal/api/v1beta1connect/role.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@ func (h *ConnectHandler) CreateRole(ctx context.Context, request *connect.Reques
if request.Msg.GetBody() == nil {
return nil, connect.NewError(connect.CodeInvalidArgument, ErrBadRequest)
}
if len(request.Msg.GetBody().GetPermissions()) == 0 {
return nil, connect.NewError(connect.CodeInvalidArgument, ErrBadRequest)
}

var metaDataMap metadata.Metadata
if request.Msg.GetBody().GetMetadata() != nil {
Expand Down
11 changes: 11 additions & 0 deletions internal/api/v1beta1connect/role_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,17 @@ func TestHandler_CreateRole(t *testing.T) {
want: nil,
wantErr: connect.NewError(connect.CodeInvalidArgument, ErrBadRequest),
},
{
name: "should return bad request error if permissions are empty",
request: connect.NewRequest(&frontierv1beta1.CreateRoleRequest{
Body: &frontierv1beta1.RoleRequestBody{
Name: testRoleMap[instanceLevelRoleID].Name,
Permissions: nil,
},
}),
want: nil,
wantErr: connect.NewError(connect.CodeInvalidArgument, ErrBadRequest),
},
{
name: "should create role on success",
setup: func(rs *mocks.RoleService, ms *mocks.MetaSchemaService) {
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
Loading
Loading