diff --git a/cmd/reconcile.go b/cmd/reconcile.go index 24d3f368b1..5037bfe10d 100644 --- a/cmd/reconcile.go +++ b/cmd/reconcile.go @@ -25,11 +25,12 @@ func ReconcileCommand(cliConfig *Config) *cli.Command { Kinds: PlatformUser (platform admins and members), Permission (custom permissions), Role (platform-level roles), Preference (platform settings), Webhook (webhook endpoints), BillingProduct (billing products - and their prices), and BillingPlan (billing plans and the products they - bundle). Deleting a permission, a custom role, or a webhook needs an - explicit 'delete: true' on its entry; nothing is deleted by omission, a - predefined role cannot be deleted, and a product or plan cannot be deleted - through the API. A preference left out of the file resets to its default. + and their prices), BillingPlan (billing plans and the products they + bundle), and MetaSchema (metadata validation schemas). Deleting a + permission, a custom role, or a webhook needs an explicit 'delete: true' + on its entry; nothing is deleted by omission, a predefined role cannot be + deleted, and a product or plan cannot be deleted through the API. A + preference left out of the file resets to its default. Log in as a superuser (for example the bootstrap service account) with --header. @@ -94,6 +95,7 @@ func buildReconcileRegistry(host, header string) (map[string]reconcile.Reconcile reconcile.KindWebhook: reconcile.NewWebhookReconciler(adminClient, header), reconcile.KindBillingProduct: reconcile.NewBillingProductReconciler(api, header), reconcile.KindBillingPlan: reconcile.NewBillingPlanReconciler(api, header), + reconcile.KindMetaSchema: reconcile.NewMetaSchemaReconciler(api, header), }, nil } diff --git a/core/metaschema/defaults.go b/core/metaschema/defaults.go new file mode 100644 index 0000000000..e189d53c8e --- /dev/null +++ b/core/metaschema/defaults.go @@ -0,0 +1,39 @@ +package metaschema + +import _ "embed" + +// Built-in metaschema names. These are the schemas the server validates entity +// metadata against, and the set the MetaSchema reconcile kind manages. +const ( + NameUser = "user" + NameGroup = "group" + NameOrg = "organization" + NameRole = "role" + NameProspect = "prospect" +) + +//go:embed metaschemas/user.json +var defaultUser []byte + +//go:embed metaschemas/group.json +var defaultGroup []byte + +//go:embed metaschemas/org.json +var defaultOrg []byte + +//go:embed metaschemas/role.json +var defaultRole []byte + +//go:embed metaschemas/prospect.json +var defaultProspect []byte + +// Defaults maps each built-in metaschema name to its shipped JSON schema. It is +// the one source for both the server seeding (MigrateDefaults) and the MetaSchema +// reconcile kind, so adding a metaschema for a new resource is a single edit here. +var Defaults = map[string]string{ + NameUser: string(defaultUser), + NameGroup: string(defaultGroup), + NameOrg: string(defaultOrg), + NameRole: string(defaultRole), + NameProspect: string(defaultProspect), +} diff --git a/core/metaschema/defaults_test.go b/core/metaschema/defaults_test.go new file mode 100644 index 0000000000..c2da79f6ec --- /dev/null +++ b/core/metaschema/defaults_test.go @@ -0,0 +1,27 @@ +package metaschema + +import ( + "encoding/json" + "testing" +) + +func TestDefaults(t *testing.T) { + want := []string{NameUser, NameGroup, NameOrg, NameRole, NameProspect} + if len(Defaults) != len(want) { + t.Fatalf("Defaults has %d entries, want %d", len(Defaults), len(want)) + } + for _, name := range want { + schema, ok := Defaults[name] + if !ok { + t.Errorf("Defaults is missing %q", name) + continue + } + if schema == "" { + t.Errorf("Defaults[%q] is empty", name) + } + var v any + if err := json.Unmarshal([]byte(schema), &v); err != nil { + t.Errorf("Defaults[%q] is not valid JSON: %v", name, err) + } + } +} diff --git a/internal/store/postgres/metaschemas/group.json b/core/metaschema/metaschemas/group.json similarity index 100% rename from internal/store/postgres/metaschemas/group.json rename to core/metaschema/metaschemas/group.json diff --git a/internal/store/postgres/metaschemas/org.json b/core/metaschema/metaschemas/org.json similarity index 100% rename from internal/store/postgres/metaschemas/org.json rename to core/metaschema/metaschemas/org.json diff --git a/internal/store/postgres/metaschemas/prospect.json b/core/metaschema/metaschemas/prospect.json similarity index 100% rename from internal/store/postgres/metaschemas/prospect.json rename to core/metaschema/metaschemas/prospect.json diff --git a/internal/store/postgres/metaschemas/role.json b/core/metaschema/metaschemas/role.json similarity index 100% rename from internal/store/postgres/metaschemas/role.json rename to core/metaschema/metaschemas/role.json diff --git a/internal/store/postgres/metaschemas/user.json b/core/metaschema/metaschemas/user.json similarity index 100% rename from internal/store/postgres/metaschemas/user.json rename to core/metaschema/metaschemas/user.json diff --git a/docs/content/docs/reconcile.mdx b/docs/content/docs/reconcile.mdx index be620dd018..5a786e7448 100644 --- a/docs/content/docs/reconcile.mdx +++ b/docs/content/docs/reconcile.mdx @@ -358,6 +358,31 @@ spec: timestamps, and metadata are server-owned or out of scope and not written; out-of-scope plans are left out too. +## The MetaSchema kind + +`MetaSchema` manages the JSON schemas that validate the `metadata` field on the +built-in entities: user, group, organization, role, and prospect. Each is a +value: the file sets its schema, and one left out resets to the shipped default. +There is no delete flag, and a name outside the built-in set is rejected. + +```yaml +apiVersion: v1 +kind: MetaSchema +spec: + - name: organization + schema: | + { + "type": "object", + "properties": { "cost_center": { "type": "string" } }, + "required": ["cost_center"] + } +``` + +The schema is a JSON string. Export writes only the built-ins whose schema differs +from the default, so a freshly exported file lists just what an operator changed. + +A metaschema change reaches every pod within the server's cache refresh interval, about a minute by default, so it can take that long to take effect everywhere. + ## Running it Log in as a superuser. The bootstrap service user exists for exactly this; its client id @@ -401,7 +426,7 @@ The kind argument is case-insensitive and accepts a plural, so `platformuser` an ## More kinds This page covers `PlatformUser`, `Permission`, `Role`, `Preference`, `Webhook`, -`BillingProduct`, and `BillingPlan`. The design and +`BillingProduct`, `BillingPlan`, and `MetaSchema`. 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 diff --git a/docs/rfcs/0001-declarative-reconcile.md b/docs/rfcs/0001-declarative-reconcile.md index 96e3c41303..4cbba0ddd2 100644 --- a/docs/rfcs/0001-declarative-reconcile.md +++ b/docs/rfcs/0001-declarative-reconcile.md @@ -56,6 +56,7 @@ role is a value. | Role, predefined | value | name | reset to the shipped definition | cannot be removed | | Preference | value | trait name | reset to the trait default | leave the entry out, it resets | | Webhook | object | URL | plan fails | set `delete: true` | +| MetaSchema | value | name | reset to the shipped schema | leave the entry out, it resets | Every kind, current and future, follows the same five rules: @@ -229,6 +230,12 @@ identity. An empty event set means all events, which is the server default. The is server-owned: the server makes it on create and never returns it, so it is never in the file, a plan, or an export. +**MetaSchema.** An entry is `{name, schema}`. The name is one of the built-in +schemas the server validates entity metadata against: user, group, organization, +role, and prospect. Each is a value whose default is the shipped schema. The file +sets a schema; a built-in left out resets to its default. There is no delete flag, +and a name outside the built-in set is rejected. The schema is a JSON string. + ## Server-side changes Two boot behaviors changed to make this flow work. @@ -278,7 +285,6 @@ roles, committed as the desired-state files, then dropping the setting from the - A read-only API that returns the server's own predefined-role definitions, so the reset target comes from the running server instead of the CLI's compiled copy. This removes the image-version coupling. -- Metaschemas as a kind, once the server stops caching them per pod at boot. - Passing the auth token without putting it in the process arguments. - Billing plans as a kind, replacing the boot-time plans loader. - Removing relation-based ownership from the base schema, so narrowing a predefined role diff --git a/internal/reconcile/metaschema.go b/internal/reconcile/metaschema.go new file mode 100644 index 0000000000..810384618c --- /dev/null +++ b/internal/reconcile/metaschema.go @@ -0,0 +1,209 @@ +package reconcile + +import ( + "encoding/json" + "fmt" + "io" + "sort" + "strings" +) + +// KindMetaSchema is the desired-state document kind for entity metaschemas. +const KindMetaSchema = "MetaSchema" + +// MetaSchemaSpec is one desired metaschema. Name is a built-in metaschema name +// the server knows; Schema is the JSON schema as a string. +type MetaSchemaSpec struct { + Name string `yaml:"name"` + Schema string `yaml:"schema"` +} + +// currentMetaSchema is a metaschema as it exists on the server. +type currentMetaSchema struct { + ID string + Name string + Schema string +} + +// metaSchemaOp is a single planned change. schema is the JSON to write; id is the +// server id for an update, empty when the metaschema must be created. fromDefault +// marks a reset, so the plan can say so. +type metaSchemaOp struct { + name string + id string + schema string + fromDefault bool +} + +func (o metaSchemaOp) String() string { + switch { + case o.id == "": + return fmt.Sprintf("create metaschema %s", o.name) + case o.fromDefault: + return fmt.Sprintf("reset metaschema %s to default", o.name) + default: + return fmt.Sprintf("set metaschema %s", o.name) + } +} + +// decodeJSON parses a single JSON value, preserving number literals so a large +// or high-precision number is not rounded through float64. It rejects trailing +// data after the value, matching a strict single-document parse. +func decodeJSON(s string) (any, error) { + dec := json.NewDecoder(strings.NewReader(s)) + dec.UseNumber() + var v any + if err := dec.Decode(&v); err != nil { + return nil, err + } + if _, err := dec.Token(); err != io.EOF { + return nil, fmt.Errorf("unexpected trailing data after JSON value") + } + return v, nil +} + +// canonicalJSON returns a stable form of a JSON document, so two schemas that +// differ only in whitespace or key order compare equal, while a real difference +// in a number is kept (numbers are compared by their literal, not by float64). +// It keeps the export round-trip stable and stops formatting from making a false +// diff. +func canonicalJSON(s string) (string, error) { + v, err := decodeJSON(s) + if err != nil { + return "", err + } + b, err := json.Marshal(v) + if err != nil { + return "", err + } + return string(b), nil +} + +// validateSchemaDocument checks the string is a JSON object, not just any valid +// JSON. A non-object root (a number, string, boolean, or array) parses as JSON +// but is not a usable metadata schema: the server compiles it with gojsonschema +// for every entity of that type, and a non-object root errors there, which would +// start failing all metadata writes for that entity. Catching it here keeps the +// whole file from partially applying. +func validateSchemaDocument(schema string) error { + root, err := decodeJSON(schema) + if err != nil { + return fmt.Errorf("schema is not valid JSON: %w", err) + } + if _, ok := root.(map[string]any); !ok { + return fmt.Errorf("schema must be a JSON object") + } + return nil +} + +// validateMetaSchemaSpecs checks every entry without touching the server: the +// name is a known built-in, no name repeats, and the schema is a non-empty JSON +// object. defaults is the managed set and the source of known names. Names are +// matched case-insensitively, matching the sibling kinds, so `Organization` and +// `organization` both name the same built-in. +func validateMetaSchemaSpecs(specs []MetaSchemaSpec, defaults map[string]string) error { + seen := map[string]struct{}{} + for _, s := range specs { + name := strings.ToLower(strings.TrimSpace(s.Name)) + if name == "" { + return fmt.Errorf("metaschema name is required") + } + if _, ok := defaults[name]; !ok { + return fmt.Errorf("unknown metaschema %q", s.Name) + } + if _, dup := seen[name]; dup { + return fmt.Errorf("metaschema %q is listed more than once", name) + } + seen[name] = struct{}{} + if strings.TrimSpace(s.Schema) == "" { + return fmt.Errorf("metaschema %q: schema is required", name) + } + if err := validateSchemaDocument(s.Schema); err != nil { + return fmt.Errorf("metaschema %q: %w", name, err) + } + } + return nil +} + +// diffMetaSchemas returns the ops that make the server's built-in metaschemas +// match the desired spec. The file is the full desired state: a built-in the file +// lists is set to its schema, and a built-in the file leaves out is reset to its +// shipped default. defaults is the managed set: its keys are the built-ins, its +// values the reset targets. +func diffMetaSchemas(desired []MetaSchemaSpec, current []currentMetaSchema, defaults map[string]string) ([]metaSchemaOp, error) { + desiredByName := make(map[string]string, len(desired)) + for _, s := range desired { + desiredByName[strings.ToLower(strings.TrimSpace(s.Name))] = s.Schema + } + currentByName := make(map[string]currentMetaSchema, len(current)) + for _, c := range current { + currentByName[c.Name] = c + } + + names := make([]string, 0, len(defaults)) + for name := range defaults { + names = append(names, name) + } + sort.Strings(names) + + var ops []metaSchemaOp + for _, name := range names { + want, inFile := desiredByName[name] + if !inFile { + want = defaults[name] + } + wantCanon, err := canonicalJSON(want) + if err != nil { + return nil, fmt.Errorf("metaschema %q: schema is not valid JSON: %w", name, err) + } + + cur, exists := currentByName[name] + if exists { + if curCanon, err := canonicalJSON(cur.Schema); err == nil && curCanon == wantCanon { + continue + } + } + ops = append(ops, metaSchemaOp{ + name: name, + id: cur.ID, // empty when the metaschema is not on the server + schema: want, + fromDefault: !inFile, + }) + } + return ops, nil +} + +// exportMetaSchemas returns the built-ins whose current schema differs from the +// default, sorted by name, as a desired-state spec. A built-in at its default is +// omitted, so reconciling an export plans no changes. +func exportMetaSchemas(current []currentMetaSchema, defaults map[string]string) ([]MetaSchemaSpec, error) { + byName := make(map[string]currentMetaSchema, len(current)) + for _, c := range current { + byName[c.Name] = c + } + names := make([]string, 0, len(defaults)) + for name := range defaults { + names = append(names, name) + } + sort.Strings(names) + + var specs []MetaSchemaSpec + for _, name := range names { + cur, exists := byName[name] + if !exists { + continue + } + curCanon, err := canonicalJSON(cur.Schema) + if err != nil { + // A stored schema that is not valid JSON still round-trips as its own + // literal, so emit it rather than dropping it. + specs = append(specs, MetaSchemaSpec{Name: name, Schema: cur.Schema}) + continue + } + if defCanon, err := canonicalJSON(defaults[name]); err == nil && defCanon == curCanon { + continue + } + specs = append(specs, MetaSchemaSpec{Name: name, Schema: cur.Schema}) + } + return specs, nil +} diff --git a/internal/reconcile/metaschema_reconciler.go b/internal/reconcile/metaschema_reconciler.go new file mode 100644 index 0000000000..b3c20a7e53 --- /dev/null +++ b/internal/reconcile/metaschema_reconciler.go @@ -0,0 +1,112 @@ +package reconcile + +import ( + "context" + "fmt" + + "connectrpc.com/connect" + "github.com/raystack/frontier/core/metaschema" + frontierv1beta1 "github.com/raystack/frontier/proto/v1beta1" +) + +// MetaSchemaAPI is the API subset the metaschema reconciler needs. Every call +// lives on FrontierService; the caller provides one value that serves it. There +// is no delete: built-in metaschemas are values, reset but never removed. +type MetaSchemaAPI interface { + ListMetaSchemas(context.Context, *connect.Request[frontierv1beta1.ListMetaSchemasRequest]) (*connect.Response[frontierv1beta1.ListMetaSchemasResponse], error) + CreateMetaSchema(context.Context, *connect.Request[frontierv1beta1.CreateMetaSchemaRequest]) (*connect.Response[frontierv1beta1.CreateMetaSchemaResponse], error) + UpdateMetaSchema(context.Context, *connect.Request[frontierv1beta1.UpdateMetaSchemaRequest]) (*connect.Response[frontierv1beta1.UpdateMetaSchemaResponse], error) +} + +// MetaSchemaReconciler makes the built-in metaschemas match the desired spec. The +// name is the identity; the JSON schema is the managed value. Built-ins are +// values: one left out of the file resets to its shipped default. +type MetaSchemaReconciler struct { + client MetaSchemaAPI + header string + defaults map[string]string +} + +func NewMetaSchemaReconciler(client MetaSchemaAPI, header string) *MetaSchemaReconciler { + return &MetaSchemaReconciler{client: client, header: header, defaults: metaschema.Defaults} +} + +func (r *MetaSchemaReconciler) Kind() string { return KindMetaSchema } + +// Validate checks every entry without touching the server, so a bad entry stops +// the whole file before anything applies. +func (r *MetaSchemaReconciler) Validate(spec []byte) error { + var specs []MetaSchemaSpec + if err := decodeSpec(spec, &specs); err != nil { + return fmt.Errorf("parse %s spec: %w", KindMetaSchema, err) + } + return validateMetaSchemaSpecs(specs, r.defaults) +} + +func (r *MetaSchemaReconciler) Reconcile(ctx context.Context, spec []byte, dryRun bool) (Report, error) { + var specs []MetaSchemaSpec + if err := decodeSpec(spec, &specs); err != nil { + return Report{}, fmt.Errorf("parse %s spec: %w", KindMetaSchema, err) + } + + current, err := r.fetchCurrent(ctx) + if err != nil { + return Report{}, err + } + + ops, err := diffMetaSchemas(specs, current, r.defaults) + if err != nil { + return Report{}, err + } + + rep := Report{Kind: KindMetaSchema, DryRun: dryRun} + for _, op := range ops { + rep.Planned = append(rep.Planned, op.String()) + } + if dryRun { + return rep, nil + } + for _, op := range ops { + if err := r.apply(ctx, op); err != nil { + return rep, fmt.Errorf("apply [%s]: %w", op, err) + } + rep.Applied++ + } + return rep, nil +} + +// Export returns the built-ins whose schema differs from the default as a +// desired-state spec, so reconciling it plans no changes. +func (r *MetaSchemaReconciler) Export(ctx context.Context) (any, error) { + current, err := r.fetchCurrent(ctx) + if err != nil { + return nil, err + } + return exportMetaSchemas(current, r.defaults) +} + +func (r *MetaSchemaReconciler) fetchCurrent(ctx context.Context) ([]currentMetaSchema, error) { + resp, err := r.client.ListMetaSchemas(ctx, authReq(&frontierv1beta1.ListMetaSchemasRequest{}, r.header)) + if err != nil { + return nil, fmt.Errorf("list metaschemas: %w", err) + } + var current []currentMetaSchema + for _, ms := range resp.Msg.GetMetaschemas() { + current = append(current, currentMetaSchema{ + ID: ms.GetId(), + Name: ms.GetName(), + Schema: ms.GetSchema(), + }) + } + return current, nil +} + +func (r *MetaSchemaReconciler) apply(ctx context.Context, op metaSchemaOp) error { + body := &frontierv1beta1.MetaSchemaRequestBody{Name: op.name, Schema: op.schema} + if op.id == "" { + _, err := r.client.CreateMetaSchema(ctx, authReq(&frontierv1beta1.CreateMetaSchemaRequest{Body: body}, r.header)) + return err + } + _, err := r.client.UpdateMetaSchema(ctx, authReq(&frontierv1beta1.UpdateMetaSchemaRequest{Id: op.id, Body: body}, r.header)) + return err +} diff --git a/internal/reconcile/metaschema_reconciler_test.go b/internal/reconcile/metaschema_reconciler_test.go new file mode 100644 index 0000000000..d937f4c3af --- /dev/null +++ b/internal/reconcile/metaschema_reconciler_test.go @@ -0,0 +1,146 @@ +package reconcile + +import ( + "context" + "testing" + + "connectrpc.com/connect" + "github.com/raystack/frontier/core/metaschema" + frontierv1beta1 "github.com/raystack/frontier/proto/v1beta1" + "github.com/stretchr/testify/assert" +) + +type updateCall struct { + id string + name string + schema string +} + +type fakeMetaSchemaAPI struct { + schemas []*frontierv1beta1.MetaSchema + created []*frontierv1beta1.MetaSchemaRequestBody + updated []updateCall +} + +func (f *fakeMetaSchemaAPI) ListMetaSchemas(_ context.Context, _ *connect.Request[frontierv1beta1.ListMetaSchemasRequest]) (*connect.Response[frontierv1beta1.ListMetaSchemasResponse], error) { + return connect.NewResponse(&frontierv1beta1.ListMetaSchemasResponse{Metaschemas: f.schemas}), nil +} + +func (f *fakeMetaSchemaAPI) CreateMetaSchema(_ context.Context, req *connect.Request[frontierv1beta1.CreateMetaSchemaRequest]) (*connect.Response[frontierv1beta1.CreateMetaSchemaResponse], error) { + f.created = append(f.created, req.Msg.GetBody()) + return connect.NewResponse(&frontierv1beta1.CreateMetaSchemaResponse{}), nil +} + +func (f *fakeMetaSchemaAPI) UpdateMetaSchema(_ context.Context, req *connect.Request[frontierv1beta1.UpdateMetaSchemaRequest]) (*connect.Response[frontierv1beta1.UpdateMetaSchemaResponse], error) { + f.updated = append(f.updated, updateCall{id: req.Msg.GetId(), name: req.Msg.GetBody().GetName(), schema: req.Msg.GetBody().GetSchema()}) + return connect.NewResponse(&frontierv1beta1.UpdateMetaSchemaResponse{}), nil +} + +// seededDefaults returns the five built-ins at their default schema, each with a +// stable fake id of "-id". +func seededDefaults() []*frontierv1beta1.MetaSchema { + var out []*frontierv1beta1.MetaSchema + for name, schema := range metaschema.Defaults { + out = append(out, &frontierv1beta1.MetaSchema{Id: name + "-id", Name: name, Schema: schema}) + } + return out +} + +func TestMetaSchemaReconciler(t *testing.T) { + t.Run("updates a built-in the file overrides", func(t *testing.T) { + api := &fakeMetaSchemaAPI{schemas: seededDefaults()} + spec := []byte("- {name: organization, schema: '{\"type\":\"object\",\"required\":[\"cost_center\"]}'}\n") + + rep, err := NewMetaSchemaReconciler(api, "").Reconcile(context.Background(), spec, false) + + assert.NoError(t, err) + assert.Equal(t, []string{"set metaschema organization"}, rep.Planned) + assert.Equal(t, 1, rep.Applied) + assert.Empty(t, api.created) + assert.Len(t, api.updated, 1) + assert.Equal(t, "organization-id", api.updated[0].id) + }) + + t.Run("dry run plans but applies nothing", func(t *testing.T) { + api := &fakeMetaSchemaAPI{schemas: seededDefaults()} + spec := []byte("- {name: organization, schema: '{\"type\":\"object\",\"required\":[\"cost_center\"]}'}\n") + + rep, err := NewMetaSchemaReconciler(api, "").Reconcile(context.Background(), spec, true) + + assert.NoError(t, err) + assert.Equal(t, []string{"set metaschema organization"}, rep.Planned) + assert.Equal(t, 0, rep.Applied) + assert.Empty(t, api.updated) + }) + + t.Run("resets a built-in that holds an override and is left out", func(t *testing.T) { + seeded := seededDefaults() + for _, ms := range seeded { + if ms.GetName() == metaschema.NameOrg { + ms.Schema = `{"type":"string"}` // an override + } + } + api := &fakeMetaSchemaAPI{schemas: seeded} + + rep, err := NewMetaSchemaReconciler(api, "").Reconcile(context.Background(), []byte("[]\n"), false) + + assert.NoError(t, err) + assert.Equal(t, []string{"reset metaschema organization to default"}, rep.Planned) + assert.Len(t, api.updated, 1) + assert.Equal(t, metaschema.Defaults[metaschema.NameOrg], api.updated[0].schema) + }) + + t.Run("creates a built-in missing on the server", func(t *testing.T) { + var seeded []*frontierv1beta1.MetaSchema + for _, ms := range seededDefaults() { + if ms.GetName() == metaschema.NameProspect { + continue // server does not have it yet + } + seeded = append(seeded, ms) + } + api := &fakeMetaSchemaAPI{schemas: seeded} + + rep, err := NewMetaSchemaReconciler(api, "").Reconcile(context.Background(), []byte("[]\n"), false) + + assert.NoError(t, err) + assert.Equal(t, []string{"create metaschema prospect"}, rep.Planned) + assert.Len(t, api.created, 1) + assert.Equal(t, metaschema.NameProspect, api.created[0].GetName()) + }) + + t.Run("rejects an unknown name at validate", func(t *testing.T) { + err := NewMetaSchemaReconciler(&fakeMetaSchemaAPI{}, "").Validate([]byte("- {name: widget, schema: '{}'}\n")) + assert.ErrorContains(t, err, "unknown metaschema") + }) + + // R2 value model: there is no delete, so a delete flag is an unknown field. + t.Run("R2 rejects a delete flag since metaschemas are values", func(t *testing.T) { + err := NewMetaSchemaReconciler(&fakeMetaSchemaAPI{}, "").Validate([]byte("- {name: organization, schema: '{\"type\":\"object\"}', delete: true}\n")) + assert.Error(t, err) + }) + + // R3 check the whole file first: one bad entry fails validation for the doc, + // so nothing applies. + t.Run("R3 a bad entry fails validation for the whole document", func(t *testing.T) { + spec := []byte("- {name: organization, schema: '{\"type\":\"object\"}'}\n- {name: widget, schema: '{\"type\":\"object\"}'}\n") + err := NewMetaSchemaReconciler(&fakeMetaSchemaAPI{}, "").Validate(spec) + assert.ErrorContains(t, err, "unknown metaschema") + }) + + t.Run("exports only overridden built-ins", func(t *testing.T) { + seeded := seededDefaults() + for _, ms := range seeded { + if ms.GetName() == metaschema.NameOrg { + ms.Schema = `{"type":"object","required":["cost_center"]}` + } + } + api := &fakeMetaSchemaAPI{schemas: seeded} + + out, err := NewMetaSchemaReconciler(api, "").Export(context.Background()) + + assert.NoError(t, err) + specs, ok := out.([]MetaSchemaSpec) + assert.True(t, ok) + assert.Equal(t, []MetaSchemaSpec{{Name: metaschema.NameOrg, Schema: `{"type":"object","required":["cost_center"]}`}}, specs) + }) +} diff --git a/internal/reconcile/metaschema_test.go b/internal/reconcile/metaschema_test.go new file mode 100644 index 0000000000..c78d669ccb --- /dev/null +++ b/internal/reconcile/metaschema_test.go @@ -0,0 +1,251 @@ +package reconcile + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +// small controlled default set for the pure tests +var testMetaDefaults = map[string]string{ + "user": `{"type":"object"}`, + "organization": `{"type":"object","properties":{}}`, +} + +func TestValidateMetaSchemaSpecs(t *testing.T) { + t.Run("accepts a known built-in", func(t *testing.T) { + err := validateMetaSchemaSpecs([]MetaSchemaSpec{{Name: "user", Schema: `{"type":"object"}`}}, testMetaDefaults) + assert.NoError(t, err) + }) + t.Run("rejects an unknown name", func(t *testing.T) { + err := validateMetaSchemaSpecs([]MetaSchemaSpec{{Name: "widget", Schema: `{}`}}, testMetaDefaults) + assert.ErrorContains(t, err, "unknown metaschema") + }) + t.Run("rejects a duplicate name", func(t *testing.T) { + err := validateMetaSchemaSpecs([]MetaSchemaSpec{ + {Name: "user", Schema: `{}`}, + {Name: "user", Schema: `{}`}, + }, testMetaDefaults) + assert.ErrorContains(t, err, "more than once") + }) + t.Run("rejects an empty schema", func(t *testing.T) { + err := validateMetaSchemaSpecs([]MetaSchemaSpec{{Name: "user", Schema: " "}}, testMetaDefaults) + assert.ErrorContains(t, err, "schema is required") + }) + t.Run("rejects invalid JSON", func(t *testing.T) { + err := validateMetaSchemaSpecs([]MetaSchemaSpec{{Name: "user", Schema: "{not json"}}, testMetaDefaults) + assert.ErrorContains(t, err, "not valid JSON") + }) + t.Run("rejects a non-object schema", func(t *testing.T) { + for _, bad := range []string{"123", `"x"`, "[]", "true"} { + err := validateMetaSchemaSpecs([]MetaSchemaSpec{{Name: "user", Schema: bad}}, testMetaDefaults) + assert.ErrorContains(t, err, "must be a JSON object", "schema %q should be rejected", bad) + } + }) + t.Run("accepts a built-in name in any case", func(t *testing.T) { + err := validateMetaSchemaSpecs([]MetaSchemaSpec{{Name: "Organization", Schema: `{"type":"object"}`}}, testMetaDefaults) + assert.NoError(t, err) + }) +} + +func TestDiffMetaSchemas(t *testing.T) { + t.Run("sets a built-in the file overrides", func(t *testing.T) { + ops, err := diffMetaSchemas( + []MetaSchemaSpec{{Name: "user", Schema: `{"type":"object","required":["x"]}`}}, + []currentMetaSchema{{ID: "user-id", Name: "user", Schema: `{"type":"object"}`}, {ID: "org-id", Name: "organization", Schema: `{"type":"object","properties":{}}`}}, + testMetaDefaults, + ) + assert.NoError(t, err) + assert.Equal(t, []string{"set metaschema user"}, planStrings(ops)) + assert.Equal(t, "user-id", ops[0].id) + }) + t.Run("no op when the file matches the server, ignoring whitespace", func(t *testing.T) { + ops, err := diffMetaSchemas( + []MetaSchemaSpec{{Name: "user", Schema: "{ \"type\": \"object\" }"}}, + []currentMetaSchema{{ID: "user-id", Name: "user", Schema: `{"type":"object"}`}, {ID: "org-id", Name: "organization", Schema: `{"type":"object","properties":{}}`}}, + testMetaDefaults, + ) + assert.NoError(t, err) + assert.Empty(t, ops) + }) + t.Run("no op when the file and server differ only in key order and whitespace", func(t *testing.T) { + ops, err := diffMetaSchemas( + []MetaSchemaSpec{{Name: "user", Schema: `{"a":1,"b":2}`}}, + []currentMetaSchema{{ID: "user-id", Name: "user", Schema: `{ "b": 2, "a": 1 }`}, {ID: "org-id", Name: "organization", Schema: `{"type":"object","properties":{}}`}}, + testMetaDefaults, + ) + assert.NoError(t, err) + assert.Empty(t, ops) + }) + t.Run("resets a built-in the file leaves out", func(t *testing.T) { + ops, err := diffMetaSchemas( + nil, + []currentMetaSchema{{ID: "user-id", Name: "user", Schema: `{"type":"object"}`}, {ID: "org-id", Name: "organization", Schema: `{"type":"string"}`}}, + testMetaDefaults, + ) + assert.NoError(t, err) + assert.Equal(t, []string{"reset metaschema organization to default"}, planStrings(ops)) + assert.Equal(t, "org-id", ops[0].id) + }) + t.Run("creates a built-in missing on the server", func(t *testing.T) { + ops, err := diffMetaSchemas( + nil, + []currentMetaSchema{{ID: "org-id", Name: "organization", Schema: `{"type":"object","properties":{}}`}}, + testMetaDefaults, + ) + assert.NoError(t, err) + assert.Equal(t, []string{"create metaschema user"}, planStrings(ops)) + assert.Equal(t, "", ops[0].id) + }) + t.Run("matches a built-in name case-insensitively", func(t *testing.T) { + ops, err := diffMetaSchemas( + []MetaSchemaSpec{{Name: "Organization", Schema: `{"type":"object","required":["x"]}`}}, + []currentMetaSchema{{ID: "user-id", Name: "user", Schema: `{"type":"object"}`}, {ID: "org-id", Name: "organization", Schema: `{"type":"object","properties":{}}`}}, + testMetaDefaults, + ) + assert.NoError(t, err) + assert.Equal(t, []string{"set metaschema organization"}, planStrings(ops)) + assert.Equal(t, "org-id", ops[0].id) + }) +} + +func TestCanonicalJSON(t *testing.T) { + t.Run("equal after whitespace normalization", func(t *testing.T) { + got, err := canonicalJSON(`{ "type" : "object" }`) + assert.NoError(t, err) + want, err := canonicalJSON(`{"type":"object"}`) + assert.NoError(t, err) + assert.Equal(t, want, got) + }) + t.Run("equal after key-order normalization", func(t *testing.T) { + got, err := canonicalJSON(`{"b":2,"a":1}`) + assert.NoError(t, err) + want, err := canonicalJSON(`{"a":1,"b":2}`) + assert.NoError(t, err) + assert.Equal(t, want, got) + }) + t.Run("rejects invalid JSON", func(t *testing.T) { + _, err := canonicalJSON("{not json") + assert.Error(t, err) + }) + t.Run("keeps a real difference in a large integer", func(t *testing.T) { + a, err := canonicalJSON(`{"maximum":10000000000000001}`) + assert.NoError(t, err) + b, err := canonicalJSON(`{"maximum":10000000000000002}`) + assert.NoError(t, err) + assert.NotEqual(t, a, b) + }) + t.Run("rejects trailing data", func(t *testing.T) { + _, err := canonicalJSON(`{"a":1} {"b":2}`) + assert.Error(t, err) + }) +} + +func TestExportMetaSchemas(t *testing.T) { + current := []currentMetaSchema{ + {ID: "user-id", Name: "user", Schema: `{"type":"object","required":["x"]}`}, // overridden + {ID: "org-id", Name: "organization", Schema: `{"type":"object","properties":{}}`}, // at default + } + t.Run("emits only overridden built-ins", func(t *testing.T) { + specs, err := exportMetaSchemas(current, testMetaDefaults) + assert.NoError(t, err) + assert.Equal(t, []MetaSchemaSpec{{Name: "user", Schema: `{"type":"object","required":["x"]}`}}, specs) + }) + t.Run("reconciling an export plans zero changes", func(t *testing.T) { + specs, err := exportMetaSchemas(current, testMetaDefaults) + assert.NoError(t, err) + ops, err := diffMetaSchemas(specs, current, testMetaDefaults) + assert.NoError(t, err) + assert.Empty(t, ops) + }) + t.Run("omits a default missing from the current server state", func(t *testing.T) { + defaults := map[string]string{ + "user": `{"type":"object"}`, + "group": `{"type":"object"}`, + } + currentMissingGroup := []currentMetaSchema{ + {ID: "user-id", Name: "user", Schema: `{"type":"object","required":["x"]}`}, + } + specs, err := exportMetaSchemas(currentMissingGroup, defaults) + assert.NoError(t, err) + assert.Equal(t, []MetaSchemaSpec{{Name: "user", Schema: `{"type":"object","required":["x"]}`}}, specs) + }) + t.Run("emits a stored schema verbatim when it is not valid JSON", func(t *testing.T) { + badCurrent := []currentMetaSchema{{ID: "user-id", Name: "user", Schema: "{not json"}} + specs, err := exportMetaSchemas(badCurrent, testMetaDefaults) + assert.NoError(t, err) + assert.Equal(t, []MetaSchemaSpec{{Name: "user", Schema: "{not json"}}, specs) + }) +} + +// TestMetaSchema_RFCRules checks the metaschema kind against the five rules of +// RFC 0001, at the pure diff/validate/export layer. +func TestMetaSchema_RFCRules(t *testing.T) { + // R1 Scope and identity: a metaschema outside the managed set is invisible. + // The diff never touches it and export never emits it. + t.Run("R1 ignores a metaschema outside the managed set", func(t *testing.T) { + current := []currentMetaSchema{ + {ID: "user-id", Name: "user", Schema: testMetaDefaults["user"]}, // at default + {ID: "org-id", Name: "organization", Schema: testMetaDefaults["organization"]}, // at default + {ID: "cust-id", Name: "custom_widget", Schema: `{"type":"object"}`}, // not a built-in + } + ops, err := diffMetaSchemas(nil, current, testMetaDefaults) + assert.NoError(t, err) + assert.Empty(t, ops) + specs, err := exportMetaSchemas(current, testMetaDefaults) + assert.NoError(t, err) + assert.Empty(t, specs) + }) + + // R4 Converge, not transact: re-reconciling a state that already matches the + // file plans nothing, so a re-run after a partial apply converges. + t.Run("R4 re-reconciling a converged state plans nothing", func(t *testing.T) { + desired := []MetaSchemaSpec{{Name: "organization", Schema: `{"type":"object","required":["cc"]}`}} + converged := []currentMetaSchema{ + {ID: "user-id", Name: "user", Schema: testMetaDefaults["user"]}, + {ID: "org-id", Name: "organization", Schema: `{"type":"object","required":["cc"]}`}, + } + ops, err := diffMetaSchemas(desired, converged, testMetaDefaults) + assert.NoError(t, err) + assert.Empty(t, ops) + }) + + // R5 Export inverts reconcile: an all-default server exports nothing, and + // reconciling that empty export plans nothing. + t.Run("R5 all-default server exports nothing and round-trips to zero", func(t *testing.T) { + current := []currentMetaSchema{ + {ID: "user-id", Name: "user", Schema: testMetaDefaults["user"]}, + {ID: "org-id", Name: "organization", Schema: testMetaDefaults["organization"]}, + } + specs, err := exportMetaSchemas(current, testMetaDefaults) + assert.NoError(t, err) + assert.Empty(t, specs) + ops, err := diffMetaSchemas(specs, current, testMetaDefaults) + assert.NoError(t, err) + assert.Empty(t, ops) + }) + + // R5 Export inverts reconcile: an overridden built-in round-trips exactly, + // including a large integer that float64 would round. + t.Run("R5 an overridden built-in round-trips, keeping number precision", func(t *testing.T) { + current := []currentMetaSchema{ + {ID: "user-id", Name: "user", Schema: testMetaDefaults["user"]}, + {ID: "org-id", Name: "organization", Schema: `{"type":"object","properties":{"n":{"maximum":10000000000000001}}}`}, + } + specs, err := exportMetaSchemas(current, testMetaDefaults) + assert.NoError(t, err) + assert.Len(t, specs, 1) + ops, err := diffMetaSchemas(specs, current, testMetaDefaults) + assert.NoError(t, err) + assert.Empty(t, ops) + }) +} + +// planStrings is a test helper: the String() of each op, in order. +func planStrings(ops []metaSchemaOp) []string { + out := make([]string, 0, len(ops)) + for _, op := range ops { + out = append(out, op.String()) + } + return out +} diff --git a/internal/store/postgres/metaschema_repository.go b/internal/store/postgres/metaschema_repository.go index bc2292cadf..dbf850f476 100644 --- a/internal/store/postgres/metaschema_repository.go +++ b/internal/store/postgres/metaschema_repository.go @@ -3,7 +3,6 @@ package postgres import ( "context" "database/sql" - _ "embed" "fmt" "strings" @@ -16,37 +15,6 @@ import ( "github.com/raystack/frontier/pkg/db" ) -var ( - userMetaSchemaName = "user" - groupMetaSchemaName = "group" - orgMetaSchemaName = "organization" - rolesMetaSchemaName = "role" - prospectMetaSchemaName = "prospect" -) - -//go:embed metaschemas/user.json -var defaultUser []byte - -//go:embed metaschemas/group.json -var defaultGroup []byte - -//go:embed metaschemas/org.json -var defaultOrg []byte - -//go:embed metaschemas/role.json -var defaultRole []byte - -//go:embed metaschemas/prospect.json -var defaultProspect []byte - -var defaultMetaSchemas = map[string]string{ - userMetaSchemaName: string(defaultUser), - groupMetaSchemaName: string(defaultGroup), - orgMetaSchemaName: string(defaultOrg), - rolesMetaSchemaName: string(defaultRole), - prospectMetaSchemaName: string(defaultProspect), -} - type MetaSchemaRepository struct { log *slog.Logger dbc *db.Client @@ -206,7 +174,7 @@ func (m MetaSchemaRepository) Update(ctx context.Context, id string, mschema met // add default schemas to db once during database migration func (m MetaSchemaRepository) MigrateDefaults(ctx context.Context) error { - for name, schema := range defaultMetaSchemas { + for name, schema := range metaschema.Defaults { if _, err := m.Create(ctx, metaschema.MetaSchema{ Name: name, Schema: schema,