diff --git a/internal/daemon/controller/handler_ui.go b/internal/daemon/controller/handler_ui.go index 40b8b63b5c..bc0fcf4d54 100644 --- a/internal/daemon/controller/handler_ui.go +++ b/internal/daemon/controller/handler_ui.go @@ -10,6 +10,8 @@ import ( "net/http" "strings" + "github.com/hashicorp/boundary/internal/event" + "github.com/hashicorp/boundary/internal/perms" "github.com/hashicorp/boundary/internal/ui" ) @@ -20,6 +22,19 @@ func init() { // serveMetadata provides controller metadata to the UI for licensed versions of Boundary. var serveMetadata = func(ctx context.Context, w http.ResponseWriter) {} +// serveGrantSchema provides the grant schema to the UI for autocomplete and linting support. +var serveGrantSchema = func(ctx context.Context, w http.ResponseWriter) { + const op = "controller.serveGrantSchema" + data, err := perms.BuildGrantSchemaJSON(ctx) + if err != nil { + w.WriteHeader(http.StatusNoContent) + event.WriteError(ctx, op, err) + return + } + w.Header().Set("Content-Type", "application/json") + w.Write(data) +} + func handleUiWithAssets(c *Controller) http.Handler { var nextHandler http.Handler if c.conf.RawConfig.DevUiPassthroughDir != "" { @@ -46,6 +61,9 @@ func handleUiWithAssets(c *Controller) http.Handler { case "/metadata.json": serveMetadata(c.baseContext, w) return + case "/grants-schema.json": + serveGrantSchema(c.baseContext, w) + return default: for i := dotIndex + 1; i < len(r.URL.Path); i++ { intVal := r.URL.Path[i] diff --git a/internal/perms/grants_schema.go b/internal/perms/grants_schema.go new file mode 100644 index 0000000000..a644c5efc2 --- /dev/null +++ b/internal/perms/grants_schema.go @@ -0,0 +1,119 @@ +// Copyright IBM Corp. 2020, 2025 +// SPDX-License-Identifier: BUSL-1.1 + +package perms + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/hashicorp/boundary/globals" + "github.com/hashicorp/boundary/internal/types/action" + "github.com/hashicorp/boundary/internal/types/resource" + "github.com/hashicorp/boundary/internal/types/scope" +) + +// GrantSchema represents the schema of grants in the system, +// including the resource types and their associated actions, scopes. +type GrantSchema struct { + ResourceTypes []ResourceTypeSchema `json:"resource_types"` +} + +type ResourceTypeSchema struct { + // Type is the string representation of the resource type + Type string `json:"type"` + + // The collection actions that are valid for this resource type + CollectionActions []string `json:"collection_actions,omitempty"` + + // The id actions that are valid for this resource type + IdActions []string `json:"id_actions,omitempty"` + + // The scopes that are valid for this resource type + Scopes []string `json:"scopes,omitempty"` + + // The ID prefixes that are valid for this resource type + IdPrefixes []string `json:"id_prefixes,omitempty"` + + // The parent resource type, if any, omitted if no parent + ParentType string `json:"parent_type,omitempty"` +} + +// BuildGrantSchema constructs the full grant schema from the registered +// resource types, actions, and scope definitions. +func BuildGrantSchema(ctx context.Context) (*GrantSchema, error) { + const op = "perms.BuildGrantSchema" + schema := &GrantSchema{} + + for name, typ := range resource.Map { + // Skip resource types that aren't real grantable resources + // These types will fail further lookups and aren't useful to include in the schema + if typ == resource.Unknown || typ == resource.All || typ == resource.Controller { + continue + } + + // Collect all collection actions + colActions, err := action.CollectionActionSetForResource(typ) + if err != nil { + return nil, fmt.Errorf("%s: error getting collection actions for %q: %w", op, name, err) + } + + var colStrs []string + for a := range colActions { + colStrs = append(colStrs, a.String()) + } + + // Collect all id actions + idActions, err := action.IdActionSetForResource(typ) + if err != nil { + return nil, fmt.Errorf("%s: error getting id actions for %q: %w", op, name, err) + } + + var idStrs []string + for a := range idActions { + if a == action.NoOp { + continue + } + idStrs = append(idStrs, a.String()) + } + + // Collect all scopes that can be applied to this resource type + scopes, err := scope.AllowedIn(ctx, typ) + if err != nil { + return nil, fmt.Errorf("%s: error getting allowed scopes for %q: %w", op, name, err) + } + + var scopeStrs []string + for _, s := range scopes { + scopeStrs = append(scopeStrs, s.String()) + } + + // Parent type is the resource's parent if one exists, + // otherwise this will be an empty string. + var parentType string + if parent := typ.Parent(); parent != typ { + parentType = parent.String() + } + + schema.ResourceTypes = append(schema.ResourceTypes, ResourceTypeSchema{ + Type: name, + CollectionActions: colStrs, + IdActions: idStrs, + Scopes: scopeStrs, + IdPrefixes: globals.ResourcePrefixesFromType(typ), + ParentType: parentType, + }) + } + + return schema, nil +} + +// BuildGrantSchemaJSON builds the grant schema and marshals it to JSON +func BuildGrantSchemaJSON(ctx context.Context) ([]byte, error) { + schema, err := BuildGrantSchema(ctx) + if err != nil { + return nil, err + } + return json.Marshal(schema) +} diff --git a/internal/perms/grants_schema_test.go b/internal/perms/grants_schema_test.go new file mode 100644 index 0000000000..a5d277a10f --- /dev/null +++ b/internal/perms/grants_schema_test.go @@ -0,0 +1,51 @@ +// Copyright IBM Corp. 2020, 2025 +// SPDX-License-Identifier: BUSL-1.1 + +package perms_test + +import ( + "context" + "encoding/json" + "testing" + + "github.com/hashicorp/boundary/internal/perms" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + // Import to trigger init() registrations in all service handlers + _ "github.com/hashicorp/boundary/internal/daemon/controller/handlers/accounts" + _ "github.com/hashicorp/boundary/internal/daemon/controller/handlers/aliases" + _ "github.com/hashicorp/boundary/internal/daemon/controller/handlers/authmethods" + _ "github.com/hashicorp/boundary/internal/daemon/controller/handlers/authtokens" + _ "github.com/hashicorp/boundary/internal/daemon/controller/handlers/billing" + _ "github.com/hashicorp/boundary/internal/daemon/controller/handlers/credentiallibraries" + _ "github.com/hashicorp/boundary/internal/daemon/controller/handlers/credentials" + _ "github.com/hashicorp/boundary/internal/daemon/controller/handlers/credentialstores" + _ "github.com/hashicorp/boundary/internal/daemon/controller/handlers/groups" + _ "github.com/hashicorp/boundary/internal/daemon/controller/handlers/host_catalogs" + _ "github.com/hashicorp/boundary/internal/daemon/controller/handlers/host_sets" + _ "github.com/hashicorp/boundary/internal/daemon/controller/handlers/hosts" + _ "github.com/hashicorp/boundary/internal/daemon/controller/handlers/managed_groups" + _ "github.com/hashicorp/boundary/internal/daemon/controller/handlers/policies" + _ "github.com/hashicorp/boundary/internal/daemon/controller/handlers/roles" + _ "github.com/hashicorp/boundary/internal/daemon/controller/handlers/scopes" + _ "github.com/hashicorp/boundary/internal/daemon/controller/handlers/session_recordings" + _ "github.com/hashicorp/boundary/internal/daemon/controller/handlers/sessions" + _ "github.com/hashicorp/boundary/internal/daemon/controller/handlers/storage_buckets" + _ "github.com/hashicorp/boundary/internal/daemon/controller/handlers/targets" + _ "github.com/hashicorp/boundary/internal/daemon/controller/handlers/users" + _ "github.com/hashicorp/boundary/internal/daemon/controller/handlers/workers" +) + +func TestBuildGrantSchema(t *testing.T) { + ctx := context.Background() + schema, err := perms.BuildGrantSchema(ctx) + require.NoError(t, err) + require.NotNil(t, schema) + assert.NotEmpty(t, schema.ResourceTypes, "resource types should not be empty") + + // JSON serialization should produce valid JSON + data, err := perms.BuildGrantSchemaJSON(ctx) + require.NoError(t, err) + assert.True(t, json.Valid(data), "output should be valid JSON") +}