From eba4c20a07c2aa1efae736ef95504c41cf7d2a56 Mon Sep 17 00:00:00 2001 From: Sarah Funkhouser <147884153+golanglemonade@users.noreply.github.com> Date: Mon, 16 Feb 2026 23:36:14 -0700 Subject: [PATCH] wip: port original changes Signed-off-by: Sarah Funkhouser <147884153+golanglemonade@users.noreply.github.com> --- cli/cmd/apitokens/create.go | 2 +- cli/cmd/apitokens/scopes.go | 36 ++ fga/model/helpers.go | 259 ++++++++++++ fga/model/helpers_test.go | 47 +++ fga/model/model.fga | 369 ++++++++++++++++++ go.mod | 2 +- internal/ent/hooks/apitoken.go | 127 +++--- internal/ent/hooks/errors.go | 2 + internal/ent/interceptors/filter.go | 47 ++- internal/ent/privacy/policy/base.go | 3 + internal/ent/privacy/policy/checks.go | 27 ++ internal/ent/privacy/rule/group.go | 9 + internal/ent/privacy/rule/organization.go | 13 +- internal/ent/privacy/rule/scopes.go | 151 +++++++ internal/ent/privacy/rule/scopes_test.go | 32 ++ internal/ent/schema/mixin_createacess.go | 36 +- internal/graphapi/apitoken_test.go | 175 ++++++++- internal/graphapi/control_test.go | 2 +- .../graphapi/controlimplementation_test.go | 2 +- internal/graphapi/controlobjective_test.go | 4 +- internal/graphapi/evidence_test.go | 4 +- internal/graphapi/file_test.go | 2 +- internal/graphapi/models_test.go | 11 +- internal/graphapi/narrative_test.go | 4 +- internal/graphapi/risk_test.go | 4 +- internal/graphapi/seed_test.go | 15 +- 26 files changed, 1258 insertions(+), 127 deletions(-) create mode 100644 cli/cmd/apitokens/scopes.go create mode 100644 fga/model/helpers.go create mode 100644 fga/model/helpers_test.go create mode 100644 internal/ent/privacy/rule/scopes.go create mode 100644 internal/ent/privacy/rule/scopes_test.go diff --git a/cli/cmd/apitokens/create.go b/cli/cmd/apitokens/create.go index 3bb3219421..d5a0e0e87b 100644 --- a/cli/cmd/apitokens/create.go +++ b/cli/cmd/apitokens/create.go @@ -28,7 +28,7 @@ func init() { createCmd.Flags().StringP("name", "n", "", "name of the api token token") createCmd.Flags().StringP("description", "d", "", "description of the api token") createCmd.Flags().DurationP("expiration", "e", 0, "duration of the api token to be valid, leave empty to never expire") - createCmd.Flags().StringSlice("scopes", []string{"read", "write"}, "scopes to associate with the api token") + createCmd.Flags().StringSlice("scopes", []string{"can_view", "can_edit"}, "scopes to associate with the api token"+scopeFlagConfig()) } // createValidation validates the required fields for the command diff --git a/cli/cmd/apitokens/scopes.go b/cli/cmd/apitokens/scopes.go new file mode 100644 index 0000000000..f9d264df0b --- /dev/null +++ b/cli/cmd/apitokens/scopes.go @@ -0,0 +1,36 @@ +//go:build cli + +package apitokens + +import ( + "fmt" + "sort" + "strings" + + fgamodel "github.com/theopenlane/core/fga/model" +) + +// scopeFlagConfig returns a description suffix listing available scopes. +func scopeFlagConfig() string { + scopes, err := fgamodel.RelationsForService() + if err != nil { + panic(fmt.Sprintf("failed to load service scopes: %v", err)) + } + + desc := fmt.Sprintf(" (available: %s)", strings.Join(scopes, ", ")) + + aliases := fgamodel.ScopeAliases() + if len(aliases) > 0 { + aliasPairs := make([]string, 0, len(aliases)) + + for alias, relation := range aliases { + aliasPairs = append(aliasPairs, fmt.Sprintf("%s->%s", alias, relation)) + } + + sort.Strings(aliasPairs) + + desc = fmt.Sprintf("%s; aliases: %s", desc, strings.Join(aliasPairs, ", ")) + } + + return desc +} diff --git a/fga/model/helpers.go b/fga/model/helpers.go new file mode 100644 index 0000000000..6fa4078f8a --- /dev/null +++ b/fga/model/helpers.go @@ -0,0 +1,259 @@ +package model + +import ( + _ "embed" + "encoding/json" + "maps" + "sort" + "strings" + "sync" + + openfga "github.com/openfga/go-sdk" + language "github.com/openfga/language/pkg/go/transformer" + "github.com/pkg/errors" + "google.golang.org/protobuf/encoding/protojson" +) + +const ( + // relationPartsCount is the expected number of parts when splitting a relation like "can_view_object" + relationPartsCount = 3 + // scopePartsCount is the expected number of parts when splitting a scope like "write:control" + scopePartsCount = 2 +) + +//go:embed model.fga +var embeddedModel []byte + +var ( + // CanView allows read-only access to an object + CanView string = "can_view" + // CanEdit allows read and write access to an object + CanEdit string = "can_edit" + // CanDelete allows deletion of an object + CanDelete string = "can_delete" +) + +var ( + // Read is an alias for can_view + Read string = "read" + // Write is an alias for can_edit + Write string = "write" + // Delete is an alias for can_delete + Delete string = "delete" +) + +var ( + aliasToRelation = map[string]string{ + "read": CanView, + "write": CanEdit, + "delete": CanDelete, + } + + parseOnce sync.Once + parseErr error + parsed *openfga.AuthorizationModel +) + +// GetAuthorizationModel returns the parsed embedded authorization model +func GetAuthorizationModel() (*openfga.AuthorizationModel, error) { + parseOnce.Do(func() { + protoModel, err := language.TransformDSLToProto(string(embeddedModel)) + if err != nil { + parseErr = errors.Wrap(err, "parse fga model dsl") + return + } + + rawJSON, err := protojson.Marshal(protoModel) + if err != nil { + parseErr = errors.Wrap(err, "marshal fga model") + return + } + + var model openfga.AuthorizationModel + if err := json.Unmarshal(rawJSON, &model); err != nil { + parseErr = errors.Wrap(err, "decode fga model json") + return + } + + parsed = &model + }) + + return parsed, parseErr +} + +// RelationsForService returns relations shaped like can__ that directly accept service subjects. +func RelationsForService() ([]string, error) { + model, err := GetAuthorizationModel() + if err != nil { + return nil, err + } + + var relations []string + + for _, td := range model.GetTypeDefinitions() { + if td.Metadata == nil || td.Metadata.Relations == nil { + continue + } + + for rel, meta := range *td.Metadata.Relations { + parts := strings.SplitN(rel, "_", relationPartsCount) + if len(parts) != relationPartsCount || parts[0] != "can" { + continue + } + + for _, ref := range meta.GetDirectlyRelatedUserTypes() { + if ref.Type == "service" { + relations = append(relations, rel) + break + } + } + } + } + + sort.Strings(relations) + + return relations, nil +} + +// CreateRelations returns relations shaped like can_create_ that are used for group-based creation access +func CreateRelations() ([]string, error) { + model, err := GetAuthorizationModel() + if err != nil { + return nil, err + } + + var relations []string + + for _, td := range model.GetTypeDefinitions() { + if td.Metadata == nil || td.Metadata.Relations == nil { + continue + } + + for rel, _ := range *td.Metadata.Relations { + parts := strings.SplitN(rel, "_", relationPartsCount) + if len(parts) == relationPartsCount && parts[0] == "can" && parts[1] == "create" { + relations = append(relations, rel) + } + } + } + + sort.Strings(relations) + + return relations, nil +} + +// DefaultServiceScopeSet returns the default service scopes as a set +func DefaultServiceScopeSet() (map[string]struct{}, error) { + scopes, err := RelationsForService() + if err != nil { + return nil, err + } + + set := make(map[string]struct{}, len(scopes)) + for _, s := range scopes { + set[s] = struct{}{} + } + + return set, nil +} + +// NormalizeScope returns the relation name for a provided scope, handling common aliases +// Accepts verb:object (e.g., write:control) and simple verbs (read/write/delete) +func NormalizeScope(scope string) string { + raw := strings.TrimSpace(scope) + if raw == "" { + return "" + } + + normalized := strings.ToLower(raw) + + mapVerb := func(verb string) string { + if rel, ok := aliasToRelation[verb]; ok { + return rel + } + + return verb + } + + if parts := strings.SplitN(normalized, ":", scopePartsCount); len(parts) == scopePartsCount && parts[1] != "" { + return mapVerb(parts[0]) + "_" + parts[1] + } + + if rel := mapVerb(normalized); rel != "" { + return rel + } + + return normalized +} + +// ScopeAliases returns a copy of the supported alias mapping +func ScopeAliases() map[string]string { + aliases := make(map[string]string, len(aliasToRelation)) + maps.Copy(aliases, aliasToRelation) + + return aliases +} + +// ScopeOptions groups available scopes by object (verb mapped back via alias map) +func ScopeOptions() (map[string][]string, error) { + rels, err := RelationsForService() + if err != nil { + return nil, err + } + + relToVerb := map[string]string{} + for verb, rel := range aliasToRelation { + relToVerb[rel] = verb + } + + opts := make(map[string][]string) + + for _, rel := range rels { + parts := strings.SplitN(rel, "_", relationPartsCount) + if len(parts) != relationPartsCount || parts[0] != "can" { + continue + } + + verb, ok := relToVerb[strings.Join(parts[0:2], "_")] + if !ok { + continue + } + + obj := parts[2] + if obj == "" { + continue + } + + opts[obj] = append(opts[obj], verb) + } + + for obj := range opts { + sort.Strings(opts[obj]) + } + + return opts, nil +} + +// CreateOptions returns objects with verbs that support creation +func CreateOptions() ([]string, error) { + rels, err := CreateRelations() + if err != nil { + return nil, err + } + + objs := make([]string, 0, len(rels)) + for _, rel := range rels { + parts := strings.SplitN(rel, "_", relationPartsCount) + + obj := parts[2] + if obj == "" { + continue + } + + objs = append(objs, obj) + } + + sort.Strings(objs) + + return objs, nil +} diff --git a/fga/model/helpers_test.go b/fga/model/helpers_test.go new file mode 100644 index 0000000000..6f7c8fe33f --- /dev/null +++ b/fga/model/helpers_test.go @@ -0,0 +1,47 @@ +package model + +import ( + "testing" + + "gotest.tools/v3/assert" + is "gotest.tools/v3/assert/cmp" +) + +func TestRelationsForService(t *testing.T) { + rels, err := RelationsForService() + assert.NilError(t, err) + assert.Assert(t, rels != nil) + + // spot check + assert.Check(t, is.Contains(rels, "can_view_control")) + assert.Check(t, is.Contains(rels, "can_edit_control")) + assert.Check(t, is.Contains(rels, "can_view_evidence")) + assert.Check(t, is.Contains(rels, "can_edit_evidence")) + assert.Check(t, is.Contains(rels, "can_view_api_token")) + assert.Check(t, is.Contains(rels, "can_edit_api_token")) + assert.Check(t, is.Contains(rels, "can_delete_api_token")) +} + +func TestNormalizeScope(t *testing.T) { + assert.Equal(t, "can_view", NormalizeScope("read")) + assert.Equal(t, "can_edit", NormalizeScope("write")) + assert.Equal(t, "can_delete", NormalizeScope("delete")) + assert.Equal(t, "can_edit_control", NormalizeScope("write:control")) + assert.Equal(t, "can_view_evidence", NormalizeScope("read:evidence")) + assert.Equal(t, "can_view_api_token", NormalizeScope("read:api_token")) + assert.Equal(t, "can_edit_api_token", NormalizeScope("write:api_token")) +} + +func TestScopeOptions(t *testing.T) { + opts, err := ScopeOptions() + assert.NilError(t, err) + assert.Assert(t, opts != nil) + + assert.Check(t, is.Contains(opts, "organization")) + assert.Check(t, is.Contains(opts["organization"], "read")) + assert.Check(t, is.Contains(opts["organization"], "write")) + + assert.Check(t, is.Contains(opts, "control")) + assert.Check(t, is.Contains(opts["control"], "read")) + assert.Check(t, is.Contains(opts["control"], "write")) +} diff --git a/fga/model/model.fga b/fga/model/model.fga index f80b683775..e4347110d9 100644 --- a/fga/model/model.fga +++ b/fga/model/model.fga @@ -196,6 +196,375 @@ type organization # additional relations define user_in_context: [user] + # Additional organization level permission definitions + define can_view_user: [service] + define can_edit_user: [service] + + define can_view_service: [service] + define can_delete_user: [service] + define can_edit_service: [service] + + + # Search + define can_view_search: [service] + + # API Token + define can_view_api_token: [service, user] or can_edit_api_token + define can_edit_api_token: [service, user] or can_delete_api_token + define can_delete_api_token: [service, user] + + # Organization, do not allow api tokens to delete organizations + define can_view_organization: [service, user] or can_edit_organization + define can_edit_organization: [service, user] + + # Organization settings, also allow inheritance from organization view/edit permissions + define can_view_organization_setting: [service, user] or can_edit_organization_setting or can_view_organization + define can_edit_organization_setting: [service, user] or can_edit_organization + + # Group + define can_view_group: [service, user] or can_edit_group + define can_edit_group: [service, user] or can_delete_group + define can_delete_group: [service, user] + + # Group Setting, also allow inheritance from group view/edit permissions + define can_view_group_setting: [service, user] or can_edit_group_setting or can_view_group + define can_edit_group_setting: [service, user] or can_delete_group_setting or can_edit_group + define can_delete_group_setting: [service, user] or can_delete_group + + # Group Membership + define can_view_group_membership: [service, user] or can_edit_group_membership or can_view_group + define can_edit_group_membership: [service, user] or can_delete_group_membership or can_edit_group + define can_delete_group_membership: [service, user] or can_edit_group + + # File + define can_view_file: [service, user] or can_edit_file + define can_edit_file: [service, user] or can_delete_file + define can_delete_file: [service, user] + + # Program + define can_view_program: [service, user] or can_edit_program + define can_edit_program: [service, user] or can_delete_program + define can_delete_program: [service, user] + + # Program Membership + define can_view_program_membership: [service, user] or can_edit_program_membership or can_view_program + define can_edit_program_membership: [service, user] or can_delete_program_membership or can_edit_program + define can_delete_program_membership: [service, user] + + # Program Settings + define can_view_program_setting: [service, user] or can_edit_program_setting or can_view_program + define can_edit_program_setting: [service, user] or can_delete_program_setting or can_edit_program + define can_delete_program_setting: [service, user] + + # Control + define can_view_control: [service, user] or can_edit_control + define can_edit_control: [service, user] or can_delete_control + define can_delete_control: [service, user] + + # Subcontrol + define can_view_subcontrol: [service, user] or can_edit_subcontrol + define can_edit_subcontrol: [service, user] or can_delete_subcontrol + define can_delete_subcontrol: [service, user] + + # Control Objective + define can_view_control_objective: [service, user] or can_edit_control_objective + define can_edit_control_objective: [service, user] or can_delete_control_objective + define can_delete_control_objective: [service, user] + + # Control Implementation + define can_view_control_implementation: [service, user] or can_edit_control_implementation + define can_edit_control_implementation: [service, user] or can_delete_control_implementation + define can_delete_control_implementation: [service, user] + + # Mapped Control + define can_view_mapped_control: [service, user] or can_edit_mapped_control + define can_edit_mapped_control: [service, user] or can_delete_mapped_control + define can_delete_mapped_control: [service, user] + + # Risk + define can_view_risk: [service, user] or can_edit_risk + define can_edit_risk: [service, user] or can_delete_risk + define can_delete_risk: [service, user] + + # Narrative + define can_view_narrative: [service, user] or can_edit_narrative + define can_edit_narrative: [service, user] or can_delete_narrative + define can_delete_narrative: [service, user] + + # Action Plan + define can_view_action_plan: [service, user] or can_edit_action_plan + define can_edit_action_plan: [service, user] or can_delete_action_plan + define can_delete_action_plan: [service, user] + + # Internal Policy + define can_view_internal_policy: [service, user] or can_edit_internal_policy + define can_edit_internal_policy: [service, user] or can_delete_internal_policy + define can_delete_internal_policy: [service, user] + + # Procedure + define can_view_procedure: [service, user] or can_edit_procedure + define can_edit_procedure: [service, user] or can_delete_procedure + define can_delete_procedure: [service, user] + + # Template + define can_view_template: [service, user] or can_edit_template + define can_edit_template: [service, user] or can_delete_template + define can_delete_template: [service, user] + + # Contact + define can_view_contact: [service, user] or can_edit_contact + define can_edit_contact: [service, user] or can_delete_contact + define can_delete_contact: [service, user] + + # Entity + define can_view_entity: [service, user] or can_edit_entity + define can_edit_entity: [service, user] or can_delete_entity + define can_delete_entity: [service, user] + + # Task + define can_view_task: [service, user] or can_edit_task + define can_edit_task: [service, user] or can_delete_task + define can_delete_task: [service, user] + + # Note + define can_view_note: [service, user] or can_edit_note + define can_edit_note: [service, user] or can_delete_note + define can_delete_note: [service, user] + + # Evidence + define can_view_evidence: [service, user] or can_edit_evidence + define can_edit_evidence: [service, user] or can_delete_evidence + define can_delete_evidence: [service, user] + + # Standard + define can_view_standard: [service, user] or can_edit_standard + define can_edit_standard: [service, user] or can_delete_standard + define can_delete_standard: [service, user] + + # Job Runner + define can_view_job_runner: [service, user] or can_edit_job_runner + define can_edit_job_runner: [service, user] or can_delete_job_runner + define can_delete_job_runner: [service, user] + + # Job Template + define can_view_job_template: [service, user] or can_edit_job_template + define can_edit_job_template: [service, user] or can_delete_job_template + define can_delete_job_template: [service, user] + + # Scheduled Job + define can_view_scheduled_job: [service, user] or can_edit_scheduled_job + define can_edit_scheduled_job: [service, user] or can_delete_scheduled_job + define can_delete_scheduled_job: [service, user] + + # Trust Center + define can_view_trust_center: [service, user] or can_edit_trust_center + define can_edit_trust_center: [service, user] or can_delete_trust_center + define can_delete_trust_center: [service, user] + + # Trust Center Setting + define can_view_trust_center_setting: [service, user] or can_edit_trust_center_setting + define can_edit_trust_center_setting: [service, user] or can_delete_trust_center_setting + define can_delete_trust_center_setting: [service, user] + + # Trust Center Compliance + define can_view_trust_center_compliance: [service, user] or can_edit_trust_center_compliance + define can_edit_trust_center_compliance: [service, user] or can_delete_trust_center_compliance + define can_delete_trust_center_compliance: [service, user] + + # Trust Center Subprocessor + define can_view_trust_center_subprocessor: [service, user] or can_edit_trust_center_subprocessor + define can_edit_trust_center_subprocessor: [service, user] or can_delete_trust_center_subprocessor + define can_delete_trust_center_subprocessor: [service, user] + + # Trust Center Doc + define can_view_trust_center_doc: [service, user] or can_edit_trust_center_doc + define can_edit_trust_center_doc: [service, user] or can_delete_trust_center_doc + define can_delete_trust_center_doc: [service, user] + + # Trust Center Watermark Config + define can_view_trust_center_watermark_config: [service, user] or can_edit_trust_center_watermark_config + define can_edit_trust_center_watermark_config: [service, user] or can_delete_trust_center_watermark_config + define can_delete_trust_center_watermark_config: [service, user] + + # Export + define can_view_export: [service, user] or can_delete_export + define can_delete_export: [service, user] + + # Custom Domain + define can_view_custom_domain: [service, user] or can_edit_custom_domain + define can_edit_custom_domain: [service, user] or can_delete_custom_domain + define can_delete_custom_domain: [service, user] + + # Subprocessor + define can_view_subprocessor: [service, user] or can_edit_subprocessor + define can_edit_subprocessor: [service, user] or can_delete_subprocessor + define can_delete_subprocessor: [service, user] + + # Assessment + define can_view_assessment: [service, user] or can_edit_assessment + define can_edit_assessment: [service, user] or can_delete_assessment + define can_delete_assessment: [service, user] + + # Assessment Response + # services cannot edit an assessment response because they are filled out by users, but they can view and delete them + define can_view_assessment_response: [service, user] + define can_delete_assessment_response: [service, user] + + # Custom Type Enum + define can_view_custom_type_enum: [service, user] or can_edit_custom_type_enum + define can_edit_custom_type_enum: [service, user] or can_delete_custom_type_enum + define can_delete_custom_type_enum: [service, user] + + # Entity Type + define can_view_entity_type: [service, user] or can_edit_entity_type + define can_edit_entity_type: [service, user] or can_delete_entity_type + define can_delete_entity_type: [service, user] + + # Integrations + define can_view_integration: [service, user] or can_edit_integration + define can_edit_integration: [service, user] or can_delete_integration + define can_delete_integration: [service, user] + + # Invite + define can_view_invite: [service, user] or can_edit_invite + define can_edit_invite: [service, user] or can_delete_invite + define can_delete_invite: [service, user] + + # Subscriber + define can_view_subscriber: [service, user] or can_edit_subscriber + define can_edit_subscriber: [service, user] or can_delete_subscriber + define can_delete_subscriber: [service, user] + + # Tag Definition, also allow inheritance from organization view/edit permissions + define can_view_tag_definition: [service, user] or can_edit_tag_definition or can_view_organization + define can_edit_tag_definition: [service, user] or can_delete_tag_definition or can_edit_organization + define can_delete_tag_definition: [service, user] or can_edit_organization + + # Email Branding + define can_view_email_branding: [service, user] or can_edit_email_branding + define can_edit_email_branding: [service, user] or can_delete_email_branding + define can_delete_email_branding: [service, user] + + # Email Template + define can_view_email_template: [service, user] or can_edit_email_template + define can_edit_email_template: [service, user] or can_delete_email_template + define can_delete_email_template: [service, user] + + # Notification Template + define can_view_notification_template: [service, user] or can_edit_notification_template + define can_edit_notification_template: [service, user] or can_delete_notification_template + define can_delete_notification_template: [service, user] + + # Integration Webhook + define can_view_integration_webhook: [service, user] or can_edit_integration_webhook + define can_edit_integration_webhook: [service, user] or can_delete_integration_webhook + define can_delete_integration_webhook: [service, user] + + # Integration Run + define can_view_integration_run: [service, user] or can_edit_integration_run + define can_edit_integration_run: [service, user] or can_delete_integration_run + define can_delete_integration_run: [service, user] + + # Asset + define can_view_asset: [service, user] or can_edit_asset + define can_edit_asset: [service, user] or can_delete_asset + define can_delete_asset: [service, user] + + # Finding + define can_view_finding: [service, user] or can_edit_finding + define can_edit_finding: [service, user] or can_delete_finding + define can_delete_finding: [service, user] + + # Vulnerability + define can_view_vulnerability: [service, user] or can_edit_vulnerability + define can_edit_vulnerability: [service, user] or can_delete_vulnerability + define can_delete_vulnerability: [service, user] + + # Review + define can_view_review: [service, user] or can_edit_review + define can_edit_review: [service, user] or can_delete_review + define can_delete_review: [service, user] + + # Discussion + define can_view_discussion: [service, user] or can_edit_discussion + define can_edit_discussion: [service, user] or can_delete_discussion + define can_delete_discussion: [service, user] + + # Trust Center Entity + define can_view_trust_center_entity: [service, user] or can_edit_trust_center_entity + define can_edit_trust_center_entity: [service, user] or can_delete_trust_center_entity + define can_delete_trust_center_entity: [service, user] + + # Trust Center NDA Request + define can_view_trust_center_nda_request: [service, user] or can_edit_trust_center_nda_request + define can_edit_trust_center_nda_request: [service, user] or can_delete_trust_center_nda_request + define can_delete_trust_center_nda_request: [service, user] + + # Workflow Definition + define can_view_workflow_definition: [service, user] or can_edit_workflow_definition + define can_edit_workflow_definition: [service, user] or can_delete_workflow_definition + define can_delete_workflow_definition: [service, user] + + # Workflow Instance + define can_view_workflow_instance: [service, user] or can_edit_workflow_instance + define can_edit_workflow_instance: [service, user] or can_delete_workflow_instance + define can_delete_workflow_instance: [service, user] + + # Workflow Assignment + define can_view_workflow_assignment: [service, user] or can_edit_workflow_assignment + define can_edit_workflow_assignment: [service, user] or can_delete_workflow_assignment + define can_delete_workflow_assignment: [service, user] + + # Workflow Object Ref + define can_view_workflow_object_ref: [service, user] or can_edit_workflow_object_ref + define can_edit_workflow_object_ref: [service, user] or can_delete_workflow_object_ref + define can_delete_workflow_object_ref: [service, user] + + # Workflow Assignment Target + define can_view_workflow_assignment_target: [service, user] or can_edit_workflow_assignment_target + define can_edit_workflow_assignment_target: [service, user] or can_delete_workflow_assignment_target + define can_delete_workflow_assignment_target: [service, user] + + # Workflow Event + define can_view_workflow_event: [service, user] or can_edit_workflow_event + define can_edit_workflow_event: [service, user] or can_delete_workflow_event + define can_delete_workflow_event: [service, user] + + # Workflow Proposal + define can_view_workflow_proposal: [service, user] or can_edit_workflow_proposal + define can_edit_workflow_proposal: [service, user] or can_delete_workflow_proposal + define can_delete_workflow_proposal: [service, user] + + # Campaign + define can_view_campaign: [service, user] or can_edit_campaign + define can_edit_campaign: [service, user] or can_delete_campaign + define can_delete_campaign: [service, user] + + # Campaign Target + define can_view_campaign_target: [service, user] or can_edit_campaign_target + define can_edit_campaign_target: [service, user] or can_delete_campaign_target + define can_delete_campaign_target: [service, user] + + # Remediation + define can_view_remediation: [service, user] or can_edit_remediation + define can_edit_remediation: [service, user] or can_delete_remediation + define can_delete_remediation: [service, user] + + # Scan + define can_view_scan: [service, user] or can_edit_scan + define can_edit_scan: [service, user] or can_delete_scan + define can_delete_scan: [service, user] + + # Platform + define can_view_platform: [service, user] or can_edit_platform + define can_edit_platform: [service, user] or can_delete_platform + define can_delete_platform: [service, user] + + # Identity Holder + define can_view_identity_holder: [service, user] or can_edit_identity_holder + define can_edit_identity_holder: [service, user] or can_delete_identity_holder + define can_delete_identity_holder: [service, user] + # groups are a subset of an organization that can be used to define more fine-grained access to objects # users must be members of the organization to be members of a group # groups are all visible to all members of the organization, unless they are blocked diff --git a/go.mod b/go.mod index 7faa529b51..37ffcc183c 100644 --- a/go.mod +++ b/go.mod @@ -311,7 +311,7 @@ require ( github.com/opencontainers/image-spec v1.1.1 // indirect github.com/opencontainers/runc v1.3.3 // indirect github.com/openfga/api/proto v0.0.0-20260122164422-25e22cb1875b // indirect - github.com/openfga/language/pkg/go v0.2.0-beta.2.0.20251027165255-0f8f255e5f6c // indirect + github.com/openfga/language/pkg/go v0.2.0-beta.2.0.20251027165255-0f8f255e5f6c github.com/openfga/openfga v1.11.5 // indirect github.com/ory/dockertest v3.3.5+incompatible // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect diff --git a/internal/ent/hooks/apitoken.go b/internal/ent/hooks/apitoken.go index ce88270efd..619164e395 100644 --- a/internal/ent/hooks/apitoken.go +++ b/internal/ent/hooks/apitoken.go @@ -2,6 +2,7 @@ package hooks import ( "context" + "fmt" "time" "entgo.io/ent" @@ -10,6 +11,7 @@ import ( "github.com/theopenlane/iam/auth" + fgamodel "github.com/theopenlane/core/fga/model" "github.com/theopenlane/core/internal/ent/generated" "github.com/theopenlane/core/internal/ent/generated/hook" "github.com/theopenlane/core/pkg/logx" @@ -59,7 +61,7 @@ func HookCreateAPIToken() ent.Hook { } // create the relationship tuples in fga for the token - tuples, err := createScopeTuples(token.Scopes, orgID, token.ID) + tuples, err := createScopeTuples(ctx, token.Scopes, orgID, token.ID) if err != nil { return retVal, err } @@ -93,6 +95,24 @@ func HookCreateAPIToken() ent.Hook { func HookUpdateAPIToken() ent.Hook { return hook.On(func(next ent.Mutator) ent.Mutator { return hook.APITokenFunc(func(ctx context.Context, m *generated.APITokenMutation) (generated.Value, error) { + var oldScopes []string + var scopesModified bool + + // Only query old scopes if scopes are being modified and this is an UpdateOne operation + _, scopesModified = m.Scopes() + if !scopesModified { + // check appended + _, scopesModified = m.AppendedScopes() + } + + if scopesModified && m.Op().Is(ent.OpUpdateOne) { + var err error + oldScopes, err = m.OldScopes(ctx) + if err != nil { + return nil, err + } + } + retVal, err := next.Mutate(ctx, m) if err != nil { return nil, err @@ -106,24 +126,32 @@ func HookUpdateAPIToken() ent.Hook { at.Token = redacted - // create the relationship tuples in fga for the token - newScopes, err := getNewScopes(ctx, m) - if err != nil { - return at, err - } + // Only update scope tuples if scopes were modified + if scopesModified { + scopeSet, err := fgamodel.DefaultServiceScopeSet() + if err != nil { + return nil, fmt.Errorf("failed to load available token scopes from model: %w", err) + } - tuples, err := createScopeTuples(newScopes, at.OwnerID, at.ID) - if err != nil { - return retVal, err - } + addedScopes, removedScopes := diffScopes(oldScopes, at.Scopes) - // create the relationship tuples if we have any - if len(tuples) > 0 { - if _, err := m.Authz.WriteTupleKeys(ctx, tuples, nil); err != nil { - logx.FromContext(ctx).Error().Err(err).Msg("failed to create relationship tuple") + addTuples, err := scopeTuples(ctx, addedScopes, at.OwnerID, at.ID, scopeSet) + if err != nil { + return nil, err + } + removeTuples, err := scopeTuples(ctx, removedScopes, at.OwnerID, at.ID, scopeSet) + if err != nil { return nil, err } + + if len(addTuples) > 0 || len(removeTuples) > 0 { + if _, err := m.Authz.WriteTupleKeys(ctx, addTuples, removeTuples); err != nil { + logx.FromContext(ctx).Error().Err(err).Msg("failed to update api token scope tuples") + + return nil, err + } + } } return at, nil @@ -131,27 +159,36 @@ func HookUpdateAPIToken() ent.Hook { }, ent.OpUpdate|ent.OpUpdateOne) } -// createScopeTuples creates the relationship tuples for the token -func createScopeTuples(scopes []string, orgID, tokenID string) (tuples []fgax.TupleKey, err error) { - // create the relationship tuples in fga for the token - // TODO (sfunk): this shouldn't be a static list +// / createScopeTuples creates the relationship tuples for the token +func createScopeTuples(ctx context.Context, scopes []string, orgID, tokenID string) ([]fgax.TupleKey, error) { + scopeSet, err := fgamodel.DefaultServiceScopeSet() + if err != nil { + return nil, fmt.Errorf("failed to load available token scopes from model: %w", err) + } + + return scopeTuples(ctx, scopes, orgID, tokenID, scopeSet) +} + +// scopeTuples creates relationship tuples for the given scopes +func scopeTuples(ctx context.Context, scopes []string, orgID, tokenID string, scopeSet map[string]struct{}) ([]fgax.TupleKey, error) { + var tuples []fgax.TupleKey + for _, scope := range scopes { - var relation string - - switch scope { - case "read": - relation = "can_view" - case "write": - relation = "can_edit" - case "delete": - relation = "can_delete" - case "group_manager": - relation = "group_manager" + relation := fgamodel.NormalizeScope(scope) + + if relation == "" { + logx.FromContext(ctx).Warn().Str("scope", scope).Msg("ignoring empty scope on api token") + + continue + } + + if _, ok := scopeSet[relation]; !ok { + return nil, fmt.Errorf("%w: %q (%s)", ErrInvalidScope, scope, relation) } req := fgax.TupleRequest{ SubjectID: tokenID, - SubjectType: "service", + SubjectType: auth.ServiceSubjectType, ObjectID: orgID, ObjectType: generated.TypeOrganization, Relation: relation, @@ -160,30 +197,14 @@ func createScopeTuples(scopes []string, orgID, tokenID string) (tuples []fgax.Tu tuples = append(tuples, fgax.GetTupleKey(req)) } - return + return tuples, nil } -// getNewScopes returns the new scopes that were added to the token during an update -// NOTE: there is an AppendedScopes on the mutation, but this is not populated -// so calculating the new scopes for now -func getNewScopes(ctx context.Context, m *generated.APITokenMutation) ([]string, error) { - scopes, ok := m.Scopes() - if !ok { - return nil, nil - } - - oldScopes, err := m.OldScopes(ctx) - if err != nil { - return nil, err - } - - var newScopes []string +// diffScopes returns the added and removed scopes between two scope slices +func diffScopes(oldScopes, newScopes []string) (added []string, removed []string) { + // lo for the win + added, _ = lo.Difference(newScopes, oldScopes) + removed, _ = lo.Difference(oldScopes, newScopes) - for _, scope := range scopes { - if !lo.Contains(oldScopes, scope) { - newScopes = append(newScopes, scope) - } - } - - return newScopes, nil + return } diff --git a/internal/ent/hooks/errors.go b/internal/ent/hooks/errors.go index d91ac9f0b0..5e611286ad 100644 --- a/internal/ent/hooks/errors.go +++ b/internal/ent/hooks/errors.go @@ -195,6 +195,8 @@ var ( ErrFailedToTriggerWorkflow = errors.New("failed to trigger workflow") // ErrMissingIDForTrustCenterNDARequest is returned when a mutation for trust center nda request is missing the ID field, which is required to determine the trust center and send the appropriate email ErrMissingIDForTrustCenterNDARequest = errors.New("missing ID for trust center NDA request mutation") + // ErrInvalidScope is returned when a scope is not assignable to service subjects + ErrInvalidScope = errors.New("scope is not assignable to service subjects") ) // IsUniqueConstraintError reports if the error resulted from a DB uniqueness constraint violation. diff --git a/internal/ent/interceptors/filter.go b/internal/ent/interceptors/filter.go index b8face7106..a5bb256be1 100644 --- a/internal/ent/interceptors/filter.go +++ b/internal/ent/interceptors/filter.go @@ -2,6 +2,7 @@ package interceptors import ( "context" + "errors" "strings" "entgo.io/ent" @@ -47,7 +48,14 @@ func AddIDPredicate(ctx context.Context, q Query) error { // History uses `ref` isHistory := strings.Contains(q.Type(), "History") - objectType := getFGAObjectType(q) + objectType := rule.GetFGAObjectType(q) + + // skip filter if the api token has full organization view access for the object type + if err := rule.CheckAPITokenScope(ctx, objectType, fgax.CanView, nil); err != nil { + if errors.Is(err, privacy.Allow) { + return nil + } + } objectIDs, err := GetAuthorizedObjectIDs(ctx, objectType, fgax.CanView) if err != nil { @@ -160,7 +168,7 @@ func filterQueryResults[V any](ctx context.Context, query ent.Query, next ent.Qu return nil, err } - if skipFilter(ctx, skipperFunc...) { + if skipFilter(ctx, q, skipperFunc...) { return next.Query(ctx, query) } @@ -183,9 +191,9 @@ func filterQueryResults[V any](ctx context.Context, query ent.Query, next ent.Qu return nil, ErrRetrievingObjects } - return filterIDList(ctx, ids, getFGAObjectType(q)) + return filterIDList(ctx, ids, rule.GetFGAObjectType(q)) case ent.OpQueryOnlyID: - allow, err := singleIDCheck(ctx, v, getFGAObjectType(q)) + allow, err := singleIDCheck(ctx, v, rule.GetFGAObjectType(q)) if err != nil { return nil, err } @@ -209,8 +217,7 @@ func filterQueryResults[V any](ctx context.Context, query ent.Query, next ent.Qu } } -func skipFilter(ctx context.Context, customSkipperFunc ...skipperFunc) bool { - // by pass checks on invite or pre-allowed request +func skipFilter(ctx context.Context, q intercept.Query, customSkipperFunc ...skipperFunc) bool { // by pass checks on invite or pre-allowed request if _, allow := privacy.DecisionFromContext(ctx); allow { return true } @@ -220,6 +227,14 @@ func skipFilter(ctx context.Context, customSkipperFunc ...skipperFunc) bool { return true } + // skip filter if the api token has full organization view access for the object type + objectType := rule.GetFGAObjectType(q) + if err := rule.CheckAPITokenScope(ctx, objectType, fgax.CanView, nil); err != nil { + if errors.Is(err, privacy.Allow) { + return true + } + } + // if the custom skipper function is set and returns true, skip the filter for _, f := range customSkipperFunc { if f(ctx) { @@ -274,7 +289,7 @@ func filterListObjects[T any](ctx context.Context, v ent.Value, q intercept.Quer return nil, err } - allowedIDs, err := filterAuthorizedObjectIDs(ctx, getFGAObjectType(q), objectIDs) + allowedIDs, err := filterAuthorizedObjectIDs(ctx, rule.GetFGAObjectType(q), objectIDs) if err != nil { return nil, err } @@ -318,7 +333,7 @@ func singleObjectCheck[T any](ctx context.Context, v ent.Value, q intercept.Quer return nil, err } - allowedIDs, err := filterAuthorizedObjectIDs(ctx, getFGAObjectType(q), objectIDs) + allowedIDs, err := filterAuthorizedObjectIDs(ctx, rule.GetFGAObjectType(q), objectIDs) if err != nil { return nil, err } @@ -332,22 +347,6 @@ func singleObjectCheck[T any](ctx context.Context, v ent.Value, q intercept.Quer return v, nil } -// getFGAObjectType returns the object type for the query -// for membership tables, it will return the type with the membership suffix removed -// e.g. GroupMembership -> Group -func getFGAObjectType(q intercept.Query) string { - // Membership tables should use the object_id field, - // e.g. GroupMembership should use group_id - isMembership := strings.Contains(q.Type(), "Membership") - - objectType := q.Type() - if isMembership { - objectType = strings.ReplaceAll(q.Type(), "Membership", "") - } - - return objectType -} - // getObjectIDFromEntValues extracts the object id from a generic ent value (used for list queries) // this function should be called after the query has been successful to get the returned object ids func getObjectIDsFromEntValues(m ent.Value) ([]string, error) { diff --git a/internal/ent/privacy/policy/base.go b/internal/ent/privacy/policy/base.go index d94807d29f..89cda6635b 100644 --- a/internal/ent/privacy/policy/base.go +++ b/internal/ent/privacy/policy/base.go @@ -19,7 +19,10 @@ var prePolicy = privacy.Policy{ Mutation: privacy.MutationPolicy{ // allow internal requests (used in tests) to proceed to mutate tables rule.AllowIfInternalRequest(), + // deny mutation if missing all modules rule.DenyIfMissingAllModules(), + // allow mutation if the api token has the appropriate mutation scope + rule.AllowIfTokenHasMutationScope(), }, } diff --git a/internal/ent/privacy/policy/checks.go b/internal/ent/privacy/policy/checks.go index 89c98de0c2..859530141b 100644 --- a/internal/ent/privacy/policy/checks.go +++ b/internal/ent/privacy/policy/checks.go @@ -84,6 +84,13 @@ func CheckOrgReadAccess() privacy.QueryRule { if _, hasAnon := auth.AnonymousTrustCenterUserFromContext(ctx); hasAnon { return privacy.Deny } + + if err := rule.CheckAPITokenScope(ctx, generated.TypeOrganization, fgax.CanView, nil); err != nil { + if !errors.Is(err, privacy.Skip) { + return err + } + } + // check if the user has access to view the organization // check the query first for the IDS query, ok := q.(*generated.OrganizationQuery) @@ -105,6 +112,12 @@ func CheckOrgReadAccess() privacy.QueryRule { // some query operations func CheckOrgEditAccess() privacy.QueryRule { return privacy.QueryRuleFunc(func(ctx context.Context, _ ent.Query) error { + if err := rule.CheckAPITokenScope(ctx, generated.TypeOrganization, fgax.CanEdit, nil); err != nil { + if !errors.Is(err, privacy.Skip) { + return err + } + } + // otherwise check against the current context return rule.CheckCurrentOrgAccess(ctx, nil, fgax.CanEdit) }) @@ -122,6 +135,13 @@ func CheckOrgWriteAccess() privacy.MutationRule { func CheckOrgAccess() privacy.MutationRule { return privacy.MutationRuleFunc(func(ctx context.Context, m ent.Mutation) error { logx.FromContext(ctx).Debug().Msg("checking org read access") + + if err := rule.CheckAPITokenScope(ctx, m.Type(), fgax.CanView, nil); err != nil { + if !errors.Is(err, privacy.Skip) { + return err + } + } + return rule.CheckCurrentOrgAccess(ctx, m, fgax.CanView) }) } @@ -236,6 +256,13 @@ func checkEdgesEditAccess(ctx context.Context, m ent.Mutation, edges []string, a idStr = orgID } + // check api token scope first, as api tokens will have full access to object types they have scope for + if err := rule.CheckAPITokenScope(ctx, edgeMap.ObjectType, relationCheck, nil); err != nil { + if errors.Is(err, privacy.Allow) { + return nil + } + } + ac := fgax.AccessCheck{ Relation: relationCheck, ObjectID: idStr, diff --git a/internal/ent/privacy/rule/group.go b/internal/ent/privacy/rule/group.go index 67982f9d60..dc0a40dcaa 100644 --- a/internal/ent/privacy/rule/group.go +++ b/internal/ent/privacy/rule/group.go @@ -2,6 +2,7 @@ package rule import ( "context" + "errors" "fmt" "github.com/stoewer/go-strcase" @@ -22,6 +23,14 @@ func CheckGroupBasedObjectCreationAccess() privacy.MutationRuleFunc { return privacy.Skipf("mutation is not a create operation, skipping") } + // Check API token scope first if applicable + op := m.Op() + if err := CheckAPITokenScope(ctx, m.Type(), "", &op); err != nil { + if !errors.Is(err, privacy.Skip) { + return err + } + } + au, err := auth.GetAuthenticatedUserFromContext(ctx) if err != nil { logx.FromContext(ctx).Info().Err(err).Msg("unable to get authenticated user context") diff --git a/internal/ent/privacy/rule/organization.go b/internal/ent/privacy/rule/organization.go index 0049782525..ac56ef9672 100644 --- a/internal/ent/privacy/rule/organization.go +++ b/internal/ent/privacy/rule/organization.go @@ -33,9 +33,20 @@ func CheckCurrentOrgAccess(ctx context.Context, m ent.Mutation, relation string) return privacy.Allow } + // Check API token scope first if applicable + genericMut, ok := m.(utils.GenericMutation) + if ok { + op := genericMut.Op() + if err := CheckAPITokenScope(ctx, genericMut.Type(), relation, &op); err != nil { + if !errors.Is(err, privacy.Skip) { + return err + } + } + } + orgID, err := auth.GetOrganizationIDFromContext(ctx) if err == nil { - if relation == fgax.CanView { + if relation == fgax.CanView && !auth.IsAPITokenAuthentication(ctx) { // if the relation is view, we can skip the check return privacy.Allow } diff --git a/internal/ent/privacy/rule/scopes.go b/internal/ent/privacy/rule/scopes.go new file mode 100644 index 0000000000..8c8e0341f0 --- /dev/null +++ b/internal/ent/privacy/rule/scopes.go @@ -0,0 +1,151 @@ +package rule + +import ( + "context" + "fmt" + "strings" + + "entgo.io/ent" + "github.com/stoewer/go-strcase" + fgamodel "github.com/theopenlane/core/fga/model" + "github.com/theopenlane/iam/auth" + "github.com/theopenlane/iam/fgax" + + "github.com/theopenlane/core/internal/ent/generated" + "github.com/theopenlane/core/internal/ent/generated/intercept" + "github.com/theopenlane/core/internal/ent/generated/privacy" + "github.com/theopenlane/core/internal/ent/privacy/utils" + "github.com/theopenlane/core/pkg/logx" +) + +// scopedRelationForAPIToken returns the scoped relation for an api token based on the object type, relation, and operation +func scopedRelationForAPIToken(objectType string, relation string, op *ent.Op) string { + object := strcase.SnakeCase(objectType) + if object == "" { + return "" + } + + if op != nil { + switch { + case op.Is(ent.OpCreate), op.Is(ent.OpUpdate | ent.OpUpdateOne): + return fmt.Sprintf("can_edit_%s", object) + case op.Is(ent.OpDelete | ent.OpDeleteOne): + return fmt.Sprintf("can_delete_%s", object) + } + } + + switch relation { + case fgax.CanEdit: + return fmt.Sprintf("can_edit_%s", object) + case fgax.CanView: + return fmt.Sprintf("can_view_%s", object) + case fgax.CanDelete: + return fmt.Sprintf("can_delete_%s", object) + default: + return "" + } +} + +// getFGAObjectType returns the object type for the query +// for membership tables, it will return the type with the membership suffix removed +// e.g. GroupMembership -> Group +func GetFGAObjectType(q intercept.Query) string { + // Membership tables should use the object_id field, + // e.g. GroupMembership should use group_id + isMembership := strings.Contains(q.Type(), "Membership") + + objectType := q.Type() + if isMembership { + objectType = strings.ReplaceAll(q.Type(), "Membership", "") + } + + return objectType +} + +// AllowIfTokenHasMutationScope is a rule that allows mutation if the api token has the appropriate scope +// for the object type and operation +// this is used on the base mutation policy to enforce api token scope checks +func AllowIfTokenHasMutationScope() privacy.MutationRuleFunc { + return privacy.MutationRuleFunc(func(ctx context.Context, m ent.Mutation) error { + objectType := m.Type() + if objectType == "" { + return privacy.Skip + } + + // strip history suffix for history tables + objectType = strings.TrimSuffix(objectType, "History") + + op := m.Op() + return CheckAPITokenScope(ctx, objectType, "", &op) + }) +} + +// CheckAPITokenScope enforces that the api token has the required scope for the given object type, relation, and operation. +// Returns nil if the rule should be skipped (not an API token or no scoped relation), privacy.Allow if access is granted, or an error if denied +func CheckAPITokenScope(ctx context.Context, objectType string, relation string, op *ent.Op) error { + if !auth.IsAPITokenAuthentication(ctx) { + return privacy.Skip + } + + // allow api token access to api tokens and organizations, as they are needed for for requests + // filters will be enforced elsewhere + if objectType == generated.TypeAPIToken || objectType == generated.TypeOrganization { + return privacy.Allow + } + + scopedRelation := scopedRelationForAPIToken(objectType, relation, op) + if scopedRelation == "" { + return privacy.Skip + } + + scopeSet, err := fgamodel.DefaultServiceScopeSet() + if err != nil { + return err + } + + if _, ok := scopeSet[scopedRelation]; !ok { + logx.FromContext(ctx).Error().Str("relation", scopedRelation).Str("object_type", objectType).Msg("invalid scoped relation for api token") + + return fmt.Errorf("%w: invalid scoped relation %s for object type %s", generated.ErrPermissionDenied, scopedRelation, objectType) + } + + au, err := auth.GetAuthenticatedUserFromContext(ctx) + if err != nil { + return err + } + + orgID := au.OrganizationID + if orgID == "" { + logx.FromContext(ctx).Error().Str("relation", scopedRelation).Msg("api token missing organization scope") + + return generated.ErrPermissionDenied + } + + authzClient := utils.AuthzClientFromContext(ctx) + if authzClient == nil { + logx.FromContext(ctx).Error().Msg("missing authz client for api token scope check") + + return generated.ErrPermissionDenied + } + + ac := fgax.AccessCheck{ + SubjectID: au.SubjectID, + SubjectType: auth.GetAuthzSubjectType(ctx), + Relation: scopedRelation, + ObjectType: generated.TypeOrganization, + ObjectID: orgID, + } + + hasAccess, err := authzClient.CheckAccess(ctx, ac) + if err != nil { + logx.FromContext(ctx).Err(err).Interface("check", ac).Msg("failed api token scope check") + + return fmt.Errorf("%w: token not scoped for %s", generated.ErrPermissionDenied, scopedRelation) + } + + if hasAccess { + return privacy.Allow + } + + return generated.ErrPermissionDenied +} diff --git a/internal/ent/privacy/rule/scopes_test.go b/internal/ent/privacy/rule/scopes_test.go new file mode 100644 index 0000000000..6181ec5a41 --- /dev/null +++ b/internal/ent/privacy/rule/scopes_test.go @@ -0,0 +1,32 @@ +package rule + +import ( + "testing" + + "entgo.io/ent" + "github.com/stretchr/testify/assert" +) + +func TestScopedRelationForAPIToken(t *testing.T) { + tests := []struct { + name string + objectType string + relation string + op ent.Op + expected string + }{ + {name: "view query", objectType: "Control", relation: "can_view", expected: "can_view_control"}, + {name: "update op", objectType: "Task", relation: "can_view", op: ent.OpUpdate, expected: "can_edit_task"}, + {name: "edit relation", objectType: "Evidence", relation: "can_edit", expected: "can_edit_evidence"}, + {name: "delete op", objectType: "File", relation: "can_edit", op: ent.OpDeleteOne, expected: "can_delete_file"}, + {name: "create op", objectType: "Program", relation: "can_edit", op: ent.OpCreate, expected: "can_edit_program"}, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + actual := scopedRelationForAPIToken(tt.objectType, tt.relation, &tt.op) + assert.Equal(t, tt.expected, actual) + }) + } +} diff --git a/internal/ent/schema/mixin_createacess.go b/internal/ent/schema/mixin_createacess.go index 724b5e91cb..3d55bdc9e6 100644 --- a/internal/ent/schema/mixin_createacess.go +++ b/internal/ent/schema/mixin_createacess.go @@ -10,37 +10,21 @@ import ( "entgo.io/ent/schema/mixin" "github.com/theopenlane/iam/fgax" + fgamodel "github.com/theopenlane/core/fga/model" "github.com/theopenlane/core/internal/ent/generated/hook" "github.com/theopenlane/core/internal/ent/hooks" "github.com/theopenlane/entx/accessmap" ) -// createObjectTypes is a list of object types that access can be granted specifically for creation -// outside of the normal organization edit permissions -// TODO (sfunk): see if we can pull the annotations from the other schemas to make this dynamic -var createObjectTypes = []string{ - "control", - "control_implementation", - "control_objective", - "evidence", - "asset", - "finding", - "vulnerability", - "group", - "internal_policy", - "mapped_control", - "narrative", - "procedure", - "program", - "risk", - "scheduled_job", - "standard", - "template", - "subprocessor", - "trust_center_doc", - "trust_center_subprocessor", - "action_plan", -} +// createObjectTypes is derived from the model scopes for service subjects. +var createObjectTypes = func() []string { + opts, err := fgamodel.CreateOptions() + if err != nil { + return nil + } + + return opts +}() // GroupBasedCreateAccessMixin is a mixin for group permissions for creation of an entity // that should be added to both the to schema (Group) and the from schema (Organization) diff --git a/internal/graphapi/apitoken_test.go b/internal/graphapi/apitoken_test.go index d64faaf342..e91b7394c0 100644 --- a/internal/graphapi/apitoken_test.go +++ b/internal/graphapi/apitoken_test.go @@ -8,9 +8,11 @@ import ( "github.com/brianvoe/gofakeit/v7" "github.com/samber/lo" "github.com/theopenlane/iam/auth" + "github.com/theopenlane/iam/fgax" "gotest.tools/v3/assert" is "gotest.tools/v3/assert/cmp" + fgamodel "github.com/theopenlane/core/fga/model" "github.com/theopenlane/core/internal/ent/generated" "github.com/theopenlane/core/internal/ent/hooks" "github.com/theopenlane/core/internal/graphapi/testclient" @@ -114,7 +116,7 @@ func TestMutationCreateAPIToken(t *testing.T) { input: testclient.CreateAPITokenInput{ Name: "forthethingz", Description: &tokenDescription, - Scopes: []string{"read", "write"}, + Scopes: []string{"read:evidence", "write:evidence"}, }, }, { @@ -245,7 +247,7 @@ func TestMutationUpdateAPIToken(t *testing.T) { name: "happy path, add scope", tokenID: token.ID, input: testclient.UpdateAPITokenInput{ - Scopes: []string{"write"}, + Scopes: []string{"write:evidence"}, }, ctx: testUser1.UserCtx, }, @@ -345,7 +347,7 @@ func TestMutationDeleteAPIToken(t *testing.T) { func TestLastUsedAPIToken(t *testing.T) { // create new API token - token := (&APITokenBuilder{client: suite.client}).MustNew(testUser1.UserCtx, t) + token := (&APITokenBuilder{client: suite.client, Scopes: []string{"read:evidence", "read:api_token"}}).MustNew(testUser1.UserCtx, t) // check that the last used is empty res, err := suite.client.api.GetAPITokenByID(testUser1.UserCtx, token.ID) @@ -367,3 +369,170 @@ func TestLastUsedAPIToken(t *testing.T) { assert.NilError(t, err) assert.Check(t, !out.APIToken.LastUsedAt.IsZero()) } + +func TestAPITokenScopeEnforcement(t *testing.T) { + orgUser := suite.userBuilder(context.Background(), t) + orgCtx := auth.NewTestContextWithOrgID(orgUser.ID, orgUser.OrganizationID) + + // create scoped tokens (read-only vs write) + readToken := (&APITokenBuilder{client: suite.client, Scopes: []string{"read:organization", "read:group"}}).MustNew(orgCtx, t) + writeToken := (&APITokenBuilder{client: suite.client, Scopes: []string{"write:group"}}).MustNew(orgCtx, t) + + makeClient := func(token string) *testclient.TestClient { + authHeader := testclient.Authorization{ + BearerToken: token, + } + + c, err := testutils.TestClientWithAuth( + suite.client.db, + suite.client.objectStore, + testclient.WithCredentials(authHeader), + ) + requireNoError(t, err) + + return c + } + + readClient := makeClient(readToken.Token) + writeClient := makeClient(writeToken.Token) + + // read-only scope can fetch org details, this query includes groups so the token must have read:group scope as well + _, err := readClient.GetOrganizationByID(context.Background(), orgUser.OrganizationID) + assert.NilError(t, err) + + // read-only scope cannot create a group (requires edit) + _, err = readClient.CreateGroup(context.Background(), testclient.CreateGroupInput{ + Name: gofakeit.AppName(), + }) + assert.ErrorContains(t, err, notAuthorizedErrorMsg) + + // write scope can create a group + groupResp, err := writeClient.CreateGroup(context.Background(), testclient.CreateGroupInput{ + Name: gofakeit.AppName(), + }) + assert.NilError(t, err) + assert.Assert(t, groupResp != nil) + assert.Check(t, groupResp.CreateGroup.Group.ID != "") + + (&Cleanup[*generated.GroupDeleteOne]{client: suite.client.db.Group, IDs: []string{groupResp.CreateGroup.Group.ID}}).MustDelete(orgCtx, t) + (&Cleanup[*generated.APITokenDeleteOne]{client: suite.client.db.APIToken, IDs: []string{readToken.ID, writeToken.ID}}).MustDelete(orgCtx, t) +} + +func TestAPITokenObjectScopeTuples(t *testing.T) { + orgUser := suite.userBuilder(context.Background(), t) + orgCtx := auth.NewTestContextWithOrgID(orgUser.ID, orgUser.OrganizationID) + + evidence := (&EvidenceBuilder{client: suite.client}).MustNew(orgCtx, t) + + var tokensToCleanup []string + + defer (&Cleanup[*generated.EvidenceDeleteOne]{client: suite.client.db.Evidence, IDs: []string{evidence.ID}}).MustDelete(orgCtx, t) + defer func() { + if len(tokensToCleanup) > 0 { + (&Cleanup[*generated.APITokenDeleteOne]{client: suite.client.db.APIToken, IDs: tokensToCleanup}).MustDelete(orgCtx, t) + } + }() + + makeTokenClient := func(scopes []string) (*testclient.APIToken, *testclient.TestClient) { + resp, err := suite.client.api.CreateAPIToken(orgCtx, testclient.CreateAPITokenInput{ + Name: gofakeit.AppName(), + Scopes: scopes, + }) + assert.NilError(t, err) + + token := resp.CreateAPIToken.APIToken + tokensToCleanup = append(tokensToCleanup, token.ID) + + authHeader := testclient.Authorization{ + BearerToken: token.Token, + } + + client, err := testutils.TestClientWithAuth( + suite.client.db, + suite.client.objectStore, + testclient.WithCredentials(authHeader), + ) + assert.NilError(t, err) + + apiToken := &testclient.APIToken{ + ID: token.ID, + Name: token.Name, + Description: token.Description, + Token: token.Token, + Scopes: token.Scopes, + ExpiresAt: token.ExpiresAt, + OwnerID: token.OwnerID, + LastUsedAt: token.LastUsedAt, + } + + return apiToken, client + } + + listScopedOrgIDs := func(tokenID string, relation string) []string { + resp, err := suite.client.db.Authz.ListObjectsRequest(context.Background(), fgax.ListRequest{ + SubjectID: tokenID, + SubjectType: auth.ServiceSubjectType, + Relation: relation, + ObjectType: generated.TypeOrganization, + }) + assert.NilError(t, err) + + ids, err := fgax.GetEntityIDs(resp) + assert.NilError(t, err) + + return ids + } + + viewRelation := fgamodel.NormalizeScope("read:evidence") + editRelation := fgamodel.NormalizeScope("write:evidence") + + t.Run("read-only evidence scope", func(t *testing.T) { + token, client := makeTokenClient([]string{"read:evidence", "read:file", "read:control", "read:task", "read:subcontrol"}) + + ids := listScopedOrgIDs(token.ID, viewRelation) + assert.Check(t, lo.Contains(ids, orgUser.OrganizationID)) + + ids = listScopedOrgIDs(token.ID, editRelation) + assert.Check(t, !lo.Contains(ids, orgUser.OrganizationID)) + + _, err := client.GetEvidenceByID(context.Background(), evidence.ID) + assert.NilError(t, err) + + _, err = client.UpdateEvidence(context.Background(), evidence.ID, testclient.UpdateEvidenceInput{ + Name: lo.ToPtr(gofakeit.Word()), + }, nil) + assert.ErrorContains(t, err, notAuthorizedErrorMsg) + }) + + t.Run("scope addition and removal update tuples", func(t *testing.T) { + token, client := makeTokenClient([]string{"read:evidence", "read:file", "read:control"}) + + assert.Check(t, lo.Contains(listScopedOrgIDs(token.ID, viewRelation), orgUser.OrganizationID)) + assert.Check(t, !lo.Contains(listScopedOrgIDs(token.ID, editRelation), orgUser.OrganizationID)) + + _, err := suite.client.api.UpdateAPIToken(orgCtx, token.ID, testclient.UpdateAPITokenInput{ + AppendScopes: []string{"write:evidence"}, + }) + assert.NilError(t, err) + + assert.Check(t, lo.Contains(listScopedOrgIDs(token.ID, editRelation), orgUser.OrganizationID)) + + updatedName := gofakeit.Word() + _, err = client.UpdateEvidence(context.Background(), evidence.ID, testclient.UpdateEvidenceInput{ + Name: &updatedName, + }, nil) + assert.NilError(t, err) + + _, err = suite.client.api.UpdateAPIToken(orgCtx, token.ID, testclient.UpdateAPITokenInput{ + Scopes: []string{"read:evidence"}, + }) + assert.NilError(t, err) + + assert.Check(t, !lo.Contains(listScopedOrgIDs(token.ID, editRelation), orgUser.OrganizationID)) + + _, err = client.UpdateEvidence(context.Background(), evidence.ID, testclient.UpdateEvidenceInput{ + Name: lo.ToPtr(gofakeit.Word()), + }, nil) + assert.ErrorContains(t, err, notAuthorizedErrorMsg) + }) +} diff --git a/internal/graphapi/control_test.go b/internal/graphapi/control_test.go index e38eafc719..d9202c8d85 100644 --- a/internal/graphapi/control_test.go +++ b/internal/graphapi/control_test.go @@ -863,7 +863,7 @@ func TestMutationCreateControlsByClone(t *testing.T) { }, expectedStandard: &publicStandard.ShortName, expectedControls: controls[:1], - expectedNumProgram: 0, // api token has no program access + expectedNumProgram: 1, // api token has scopes for program access client: suite.client.apiWithToken, ctx: context.Background(), }, diff --git a/internal/graphapi/controlimplementation_test.go b/internal/graphapi/controlimplementation_test.go index 6d7d1f0fb6..4f16e605d5 100644 --- a/internal/graphapi/controlimplementation_test.go +++ b/internal/graphapi/controlimplementation_test.go @@ -214,7 +214,7 @@ func TestQueryControlImplementations(t *testing.T) { name: "happy path, using api token", client: apiClient, ctx: context.Background(), - expectedResults: numCIsWithAssociatedControls, // only the ones with linked controls will be returned + expectedResults: numCIsWithAssociatedControls + numCIs, // api token has org level access to view all controls }, { name: "happy path, using pat", diff --git a/internal/graphapi/controlobjective_test.go b/internal/graphapi/controlobjective_test.go index 0373b59e92..dec74c70e2 100644 --- a/internal/graphapi/controlobjective_test.go +++ b/internal/graphapi/controlobjective_test.go @@ -142,10 +142,10 @@ func TestQueryControlObjectives(t *testing.T) { expectedResults: 0, }, { - name: "happy path, no access to the program or group", + name: "happy path with scopes", client: suite.client.apiWithToken, ctx: context.Background(), - expectedResults: 0, + expectedResults: 2, }, { name: "happy path, using pat", diff --git a/internal/graphapi/evidence_test.go b/internal/graphapi/evidence_test.go index 9a96764386..1e30fae35a 100644 --- a/internal/graphapi/evidence_test.go +++ b/internal/graphapi/evidence_test.go @@ -164,10 +164,10 @@ func TestQueryEvidences(t *testing.T) { expectedResults: 0, }, { - name: "happy path, using api token, access not automatically granted", + name: "happy path, using api token, includes evidence scope", client: suite.client.apiWithToken, ctx: context.Background(), - expectedResults: 0, + expectedResults: 2, }, { name: "happy path, using pat, which is for the org owner so access is granted", diff --git a/internal/graphapi/file_test.go b/internal/graphapi/file_test.go index 12aa74e019..8633d37b88 100644 --- a/internal/graphapi/file_test.go +++ b/internal/graphapi/file_test.go @@ -209,7 +209,7 @@ func TestQueryFiles(t *testing.T) { name: "happy path, using api token", client: tokenClient, ctx: context.Background(), - expectedResults: 1, // 1 for evidence file, service not able to access another user's avatar file + expectedResults: 2, // access to files the organization has access to via can_view_file scope }, { name: "happy path, using pat", diff --git a/internal/graphapi/models_test.go b/internal/graphapi/models_test.go index 04e173590d..d48ce0a3eb 100644 --- a/internal/graphapi/models_test.go +++ b/internal/graphapi/models_test.go @@ -829,14 +829,13 @@ func (at *APITokenBuilder) MustNew(ctx context.Context, t *testing.T) *ent.APITo at.Description = gofakeit.HipsterSentence() } - if at.Scopes == nil { - at.Scopes = []string{"read", "write", "group_manager"} - } - request := at.client.db.APIToken.Create(). SetName(at.Name). - SetDescription(at.Description). - SetScopes(at.Scopes) + SetDescription(at.Description) + + if at.Scopes != nil { + request.SetScopes(at.Scopes) + } if at.ExpiresAt != nil { request.SetExpiresAt(*at.ExpiresAt) diff --git a/internal/graphapi/narrative_test.go b/internal/graphapi/narrative_test.go index 801fe1d38c..2dada2a062 100644 --- a/internal/graphapi/narrative_test.go +++ b/internal/graphapi/narrative_test.go @@ -149,10 +149,10 @@ func TestQueryNarratives(t *testing.T) { expectedResults: 0, }, { - name: "happy path, no access to the program or group", + name: "happy path, scope access to all narratives in org", client: suite.client.apiWithToken, ctx: context.Background(), - expectedResults: 0, + expectedResults: 2, }, { name: "happy path, using pat", diff --git a/internal/graphapi/risk_test.go b/internal/graphapi/risk_test.go index 0e4b03ad32..803917a412 100644 --- a/internal/graphapi/risk_test.go +++ b/internal/graphapi/risk_test.go @@ -141,10 +141,10 @@ func TestQueryRisks(t *testing.T) { expectedResults: 0, }, { - name: "happy path, no access to the program or group", + name: "happy path, has scope using api token", client: suite.client.apiWithToken, ctx: context.Background(), - expectedResults: 0, + expectedResults: 2, }, { name: "happy path, using pat", diff --git a/internal/graphapi/seed_test.go b/internal/graphapi/seed_test.go index bc9bfb040a..a9985f6b1a 100644 --- a/internal/graphapi/seed_test.go +++ b/internal/graphapi/seed_test.go @@ -11,6 +11,7 @@ import ( "github.com/theopenlane/core/common/enums" "github.com/theopenlane/core/common/models" + fgamodel "github.com/theopenlane/core/fga/model" ent "github.com/theopenlane/core/internal/ent/generated" "github.com/theopenlane/core/internal/graphapi/testclient" coreutils "github.com/theopenlane/core/internal/testutils" @@ -145,7 +146,19 @@ func (suite *GraphTestSuite) setupPatClient(user testUserDetails, t *testing.T) func (suite *GraphTestSuite) setupAPITokenClient(ctx context.Context, t *testing.T) *testclient.TestClient { // setup client with an API token - apiToken := (&APITokenBuilder{client: suite.client}).MustNew(ctx, t) + // setup client with an API token with comprehensive scopes for testing + // Get all available scopes from the FGA model + scopeOpts, err := fgamodel.ScopeOptions() + requireNoError(t, err) + + var scopes []string + for obj, verbs := range scopeOpts { + for _, verb := range verbs { + scopes = append(scopes, verb+":"+obj) + } + } + + apiToken := (&APITokenBuilder{client: suite.client, Scopes: scopes}).MustNew(ctx, t) authHeaderAPIToken := testclient.Authorization{ BearerToken: apiToken.Token,