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/fga.mod b/fga/model/fga.mod new file mode 100644 index 0000000000..0161dc70df --- /dev/null +++ b/fga/model/fga.mod @@ -0,0 +1,3 @@ +schema: '1.2' +contents: + - model.fga \ No newline at end of file diff --git a/fga/model/helpers.go b/fga/model/helpers.go new file mode 100644 index 0000000000..0f7d9bd48c --- /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 = "can_view" + // CanEdit allows read and write access to an object + CanEdit = "can_edit" + // CanDelete allows deletion of an object + CanDelete = "can_delete" +) + +var ( + // Read is an alias for can_view + Read = "read" + // Write is an alias for can_edit + Write = "write" + // Delete is an alias for can_delete + Delete = "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 bf4c78c5ee..11574d7dd7 100644 --- a/fga/model/model.fga +++ b/fga/model/model.fga @@ -41,21 +41,23 @@ type organization # main roles define admin: [user] or admin from parent define member: [user] or owner or admin or member from parent + define super_admin: [user] or super_admin from parent define owner: [user] or owner from parent # parent inheritance define parent: [organization] # organization policies to restrict access define access: [organization#member with email_domains_allowed] + define full_access: super_admin or owner # main permission sets based on roles define can_delete: [service] or owner or can_delete from parent - define can_edit: [service] or (admin and access) or owner or can_edit from parent + define can_edit: [service] or (admin and access) or full_access or can_edit from parent # view access of the organization # includes all members, admins, and owners # also allows users to be given time based access to the organization (e.g. support access) - define can_view: [service, user with time_based_grant] or (member and access) or owner or can_edit or can_view from parent + define can_view: [service, user with time_based_grant] or (member and access) or full_access or can_edit or can_view from parent # additional fine-grained permissions # allow owner and assigned users to view audit logs - define audit_log_viewer: ([user, service] or owner or audit_log_viewer from parent) and can_view + define audit_log_viewer: ([user, service] or full_access or audit_log_viewer from parent) and can_view # allow members to invite other members define can_invite_members: can_view or can_edit or can_invite_members from parent # only allow users with edit access to the org to invite other admins @@ -126,7 +128,7 @@ type organization define can_create_campaign_target: can_edit or campaign_target_creator define group_manager: [service] - define can_manage_groups: owner or group_manager + define can_manage_groups: full_access or group_manager define assessment_creator: [user,group#member] define can_create_assessment: can_edit or assessment_creator @@ -135,7 +137,7 @@ type organization define can_create_subprocessor: can_edit or subprocessor_creator define trust_center_admin: [user] - define can_manage_trust_center: trust_center_admin or owner + define can_manage_trust_center: trust_center_admin or full_access define trust_center_doc_creator: [group#member] define can_create_trust_center_doc: can_edit or trust_center_doc_creator or can_manage_trust_center @@ -199,6 +201,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 @@ -211,7 +582,7 @@ type group define member: [user] or admin # parent inheritance define parent: [organization with public_group] - define parent_admin: [organization#owner] + define parent_admin: [organization#owner, organization#super_admin] # permissions inherited from the organization define parent_viewer: can_view from parent or parent_admin define parent_editor: parent_admin or can_manage_groups from parent @@ -386,7 +757,7 @@ type narrative define parent: [user, service, program] define viewer: [group#member] or editor or can_view from parent define editor: [group#member, organization#owner] or can_edit from parent - define blocked: [user, group#member] + define blocked: [user, group#member] # action plans are associated with an organization but do not inherit access from the organization # the action plan creator will be made the admin of the action plan and all other access will be assigned via # associated objects or groups @@ -484,7 +855,7 @@ type email_branding define can_edit: [user, service] or (editor but not blocked) define can_delete: [user, service] or (editor but not blocked) define audit_log_viewer: ([user, service] or audit_log_viewer from parent) and can_view - define editor: [group#member, organization#owner] or can_edit from parent + define editor: [group#member, organization#owner, organization#super_admin] or can_edit from parent define viewer: [group#member] or editor or can_view from parent or member from parent define blocked: [user, group#member] define parent: [user, service, organization] @@ -497,7 +868,7 @@ type email_template define can_delete: [user, service] or (editor but not blocked) define audit_log_viewer: ([user, service] or audit_log_viewer from parent) and can_view define parent: [organization, user, service, integration, workflow_definition, workflow_instance] - define editor: [group#member, organization#owner] or can_edit from parent + define editor: [group#member, organization#owner, organization#super_admin] or can_edit from parent define viewer: [group#member] or editor or can_view from parent define blocked: [user, group#member] @@ -509,7 +880,7 @@ type notification_template define can_delete: [user, service] or (editor but not blocked) define audit_log_viewer: ([user, service] or audit_log_viewer from parent) and can_view define parent: [organization, user, service, integration, workflow_definition] - define editor: [group#member, organization#owner] or can_edit from parent + define editor: [group#member, organization#owner, organization#super_admin] or can_edit from parent define viewer: [group#member] or editor or can_view from parent define blocked: [user, group#member] @@ -520,7 +891,7 @@ type integration define can_edit: [user, service] or (editor but not blocked) define can_delete: [user, service] or (editor but not blocked) define audit_log_viewer: ([user, service] or audit_log_viewer from parent) and can_view - define editor: [group#member, organization#owner] or can_edit from parent + define editor: [group#member, organization#owner, organization#super_admin] or can_edit from parent define viewer: [group#member] or editor or can_view from parent or member from parent define blocked: [user, group#member] define parent: [organization, platform] @@ -532,7 +903,7 @@ type integration_webhook define can_edit: [user, service] or (editor but not blocked) define can_delete: [user, service] or (editor but not blocked) define audit_log_viewer: ([user, service] or audit_log_viewer from parent) and can_view - define editor: [group#member, organization#owner] or can_edit from parent + define editor: [group#member, organization#owner, organization#super_admin] or can_edit from parent define viewer: [group#member] or editor or can_view from parent define blocked: [user, group#member] define parent: [user, service, organization, integration] @@ -600,7 +971,7 @@ type entity define can_edit: [user, service] or (editor but not blocked) define can_delete: [user, service] or (editor but not blocked) define audit_log_viewer: ([user, service] or audit_log_viewer from parent) and can_view - define editor: [group#member, organization#owner] or can_edit from parent + define editor: [group#member, organization#owner, organization#super_admin] or can_edit from parent define viewer: [group#member] or editor or can_view from parent or member from parent define blocked: [user, group#member] define parent: [user, service, organization, trust_center_entity] @@ -612,7 +983,7 @@ type asset define can_edit: [user, service] or (editor but not blocked) define can_delete: [user, service] or (editor but not blocked) define audit_log_viewer: ([user, service] or audit_log_viewer from parent) and can_view - define editor: [group#member, organization#owner] or can_edit from parent + define editor: [group#member, organization#owner, organization#super_admin] or can_edit from parent define viewer: [group#member] or editor or can_view from parent or member from parent define blocked: [user, group#member] define parent: [user, service, organization, platform, entity] @@ -625,7 +996,7 @@ type finding define can_edit: [user, service] or (editor but not blocked) define can_delete: [user, service] or (editor but not blocked) define audit_log_viewer: ([user, service] or audit_log_viewer from parent) and can_view - define editor: [group#member, organization#owner] or can_edit from parent + define editor: [group#member, organization#owner, organization#super_admin] or can_edit from parent define viewer: [group#member] or editor or can_view from parent or member from parent define blocked: [user, group#member] define parent: [user, service, organization, program, control, subcontrol, risk, asset, entity, scan, directory_account, identity_holder] @@ -637,7 +1008,7 @@ type vulnerability define can_edit: [user, service] or (editor but not blocked) define can_delete: [user, service] or (editor but not blocked) define audit_log_viewer: ([user, service] or audit_log_viewer from parent) and can_view - define editor: [group#member, organization#owner] or can_edit from parent + define editor: [group#member, organization#owner, organization#super_admin] or can_edit from parent define viewer: [group#member] or editor or can_view from parent or member from parent define blocked: [user, group#member] define parent: [user, service, organization, program, control, subcontrol, risk, asset, entity, scan] @@ -649,7 +1020,7 @@ type review define can_edit: [user, service] or (editor but not blocked) define can_delete: [user, service] or (editor but not blocked) define audit_log_viewer: ([user, service] or audit_log_viewer from parent) and can_view - define editor: [group#member, organization#owner] or can_edit from parent + define editor: [group#member, organization#owner, organization#super_admin] or can_edit from parent define viewer: [group#member] or editor or can_view from parent or member from parent define blocked: [user, group#member] define parent: [user, service, organization, program, control, subcontrol, risk, action_plan, finding, vulnerability, asset, entity, task] @@ -663,7 +1034,7 @@ type contact define can_delete: ([user] and member from parent) or (editor but not blocked) define audit_log_viewer: ([user, service] or audit_log_viewer from parent) and can_view # allow a group to be assigned to add edit permissions for a set of users - define editor: [group#member] or can_edit from parent + define editor: [group#member, organization#owner, organization#super_admin] or can_edit from parent define viewer: [group#member] or editor or can_view from parent # allow users or groups to be blocked from view + edit access define blocked: [user, group#member] @@ -681,7 +1052,7 @@ type task define audit_log_viewer: ([user, service] or audit_log_viewer from parent) and can_view define parent: [user, service, program, control, procedure, internal_policy, subcontrol, control_objective, risk, task, asset, platform, identity_holder, scan, action_plan] define viewer: can_view from parent - define editor: [organization#owner] or can_edit from parent + define editor: [organization#owner, organization#super_admin] or can_edit from parent # notes are associated with a parent object and inherit view access from that object, e.g. task, policy, procedure, etc. # do not inherit edit permissions from parent here like we do on other objects, only the original user (and org owner) can edit type note @@ -691,7 +1062,7 @@ type note define can_delete: [user, service] or can_edit define audit_log_viewer: ([user, service] or audit_log_viewer from parent) and can_view define parent: [program, control, procedure, internal_policy, subcontrol, control_objective, task, trust_center, risk, evidence, discussion, trust_center_faq] - define editor: [organization#owner] + define editor: [organization#owner, organization#super_admin] define owner : [user, service] # similar to notes, discussions are associated with a parent object and inherit view access from that object, e.g. task, policy, procedure, etc. type discussion @@ -701,7 +1072,7 @@ type discussion define can_delete: [user, service] or can_edit define audit_log_viewer: ([user, service] or audit_log_viewer from parent) and can_view define parent: [user, control, procedure, internal_policy, subcontrol, control_objective, risk, evidence] - define editor: [organization#owner] or can_edit from parent + define editor: [organization#owner, organization#super_admin] or can_edit from parent define owner : [user, service] type evidence relations @@ -711,7 +1082,7 @@ type evidence define audit_log_viewer: ([user, service] or audit_log_viewer from parent) and can_view define parent: [user, service, program, control, procedure, internal_policy, subcontrol, control_objective, task, platform, scan] define viewer: [group#member] or editor - define editor: [group#member, organization#owner] + define editor: [group#member, organization#owner, organization#super_admin] define blocked: [user, group#member] type standard @@ -722,7 +1093,7 @@ type standard define can_delete: editor or parent_editor define audit_log_viewer: ([user, service] or audit_log_viewer from parent) and can_view define parent_viewer: member from parent - define parent_editor: admin from parent or owner from parent + define parent_editor: admin from parent or full_access from parent define viewer: [user, service] or editor or can_view from associated_with define editor: [user, service] # this is used for organization custom standards that are only available to the organization @@ -736,7 +1107,7 @@ type job_runner define can_delete: editor or parent_editor define audit_log_viewer: ([user, service] or audit_log_viewer from parent) and can_view define parent_viewer: member from parent - define parent_editor: admin from parent or owner from parent + define parent_editor: admin from parent or full_access from parent define viewer: [user, service] or editor define editor: [user, service] define parent: [organization] @@ -748,7 +1119,7 @@ type job_template define can_delete: [user, service] or parent_deleter or system_admin from system define parent_viewer: can_delete or can_edit or can_view from parent define parent_editor: can_delete or can_edit from parent - define parent_deleter: can_delete from parent + define parent_deleter: full_access from parent define parent: [organization] define system: [system] @@ -761,7 +1132,7 @@ type scheduled_job define parent_editor: can_delete or can_edit from parent define parent_deleter: can_delete from parent define parent: [user, service, organization, control, subcontrol] - define editor: [group#member, organization#owner] + define editor: [group#member, organization#owner, organization#super_admin] type trust_center relations @@ -770,7 +1141,7 @@ type trust_center define can_delete: [user, service] or parent_deleter define parent_viewer: can_edit from parent or can_view from parent define parent_editor: can_edit from parent or can_manage_trust_center from parent - define parent_deleter: can_delete from parent + define parent_deleter: full_access from parent define parent: [organization] define nda_signed: [user] # allow group permissions for trust center editors, this will give them @@ -880,7 +1251,7 @@ type trust_center_watermark_config define parent_editor: can_delete or can_edit from parent define parent_deleter: can_delete from parent define parent: [trust_center, user, service] - define editor: [group#member, organization#owner] or can_edit from parent + define editor: [group#member, organization#owner, organization#super_admin] or can_edit from parent define viewer: editor type export @@ -897,7 +1268,7 @@ type custom_domain define can_delete: editor or parent_editor define audit_log_viewer: ([user, service] or audit_log_viewer from parent) and can_view define parent_viewer: member from parent - define parent_editor: admin from parent or owner from parent + define parent_editor: admin from parent or full_access from parent define viewer: [user, service] or editor define editor: [user, service] define parent: [organization] @@ -912,9 +1283,9 @@ type subprocessor define admin: [user,service] or can_delete from parent # can_view from parent allows anon trust centers users to see subprocessors associated with the trust center define parent_viewer: member from parent or can_view from parent - define parent_editor: admin from parent or owner from parent + define parent_editor: admin from parent or full_access from parent define viewer: [user, service] or editor or parent_viewer - define editor: [user, service, organization#owner] or admin or parent_editor + define editor: [user, service, organization#owner, organization#super_admin] or admin or parent_editor define parent: [organization, user, trust_center_subprocessor] define system: [system] @@ -925,7 +1296,7 @@ type assessment define can_delete: [user] or owner or (editor but not blocked) define parent: [organization] # allow users or groups to be assigned edit/view permissions directly - define editor: [user, group#member] or admin from parent or owner from parent + define editor: [user, group#member] or admin from parent or full_access from parent define viewer: [user, group#member] or editor # allow users or groups to be blocked from view + edit access define blocked: [user, group#member] @@ -943,10 +1314,10 @@ type assessment_response # the user who filled out the assessment response - they can view their own response define response_owner: [user] # org admins and owners can view all responses for oversight/management - define admin_viewer: admin from parent or owner from parent + define admin_viewer: admin from parent or full_access from parent # editor and viewer relations for organization-level permissions - define editor: [organization#owner] or can_edit from parent - define viewer: [organization#owner] or editor or can_view from parent + define editor: [organization#owner, organization#super_admin] or can_edit from parent + define viewer: [organization#owner, organization#super_admin] or editor or can_view from parent # workflow definitions can be created by org admins or users in groups with create access type workflow_definition @@ -957,7 +1328,7 @@ type workflow_definition define audit_log_viewer: ([user, service] or audit_log_viewer from parent) and can_view define parent: [user, service, organization] define admin: [user, service] or can_delete from parent - define viewer: [group#member] or editor + define viewer: [group#member] or editor define editor: [group#member, organization#owner] or can_edit from parent define blocked: [user, group#member] @@ -1030,7 +1401,7 @@ type campaign define can_delete: [user, service] or (editor but not blocked) define audit_log_viewer: ([user, service] or audit_log_viewer from parent) and can_view define parent: [user, service, organization] - define editor: [group#member, organization#owner] or can_edit from parent + define editor: [group#member, organization#owner, organization#super_admin] or can_edit from parent define viewer: [group#member] or editor or can_view from parent define blocked: [user, group#member] @@ -1041,7 +1412,7 @@ type campaign_target define can_delete: [user, service] or (editor but not blocked) define audit_log_viewer: ([user, service] or audit_log_viewer from parent) and can_view define parent: [user, service, campaign] - define editor: [group#member, organization#owner] or can_edit from parent + define editor: [group#member, organization#owner, organization#super_admin] or can_edit from parent define viewer: [group#member] or editor or can_view from parent define blocked: [user, group#member] @@ -1051,7 +1422,7 @@ type remediation define can_edit: [user, service] or (editor but not blocked) define can_delete: [user, service] or (editor but not blocked) define audit_log_viewer: ([user, service] or audit_log_viewer from parent) and can_view - define editor: [group#member, organization#owner] or can_edit from parent + define editor: [group#member, organization#owner, organization#super_admin] or can_edit from parent define viewer: [group#member] or editor or can_view from parent or member from parent define blocked: [user, group#member] define parent: [user, service, organization, action_plan, program, control, subcontrol, risk, finding, vulnerability, asset, entity, task] @@ -1062,7 +1433,7 @@ type scan define can_edit: [user, service] or (editor but not blocked) define can_delete: [user, service] or (editor but not blocked) define audit_log_viewer: ([user, service] or audit_log_viewer from parent) and can_view - define editor: [group#member, organization#owner] or can_edit from parent + define editor: [group#member, organization#owner, organization#super_admin] or can_edit from parent define viewer: [group#member] or editor or can_view from parent or member from parent define blocked: [user, group#member] define parent: [user, service, organization, platform, asset] @@ -1073,7 +1444,7 @@ type platform define can_edit: [user, service] or (editor but not blocked) define can_delete: [user, service] or (editor but not blocked) define audit_log_viewer: ([user, service] or audit_log_viewer from parent) and can_view - define editor: [group#member, organization#owner] or can_edit from parent + define editor: [group#member, organization#owner, organization#super_admin] or can_edit from parent define viewer: [group#member] or editor or can_view from parent or member from parent define blocked: [user, group#member] define parent: [user, service, organization] @@ -1084,7 +1455,7 @@ type identity_holder define can_edit: [user, service] or (editor but not blocked) define can_delete: [user, service] or (editor but not blocked) define audit_log_viewer: ([user, service] or audit_log_viewer from parent) and can_view - define editor: [group#member, organization#owner] or can_edit from parent + define editor: [group#member, organization#owner, organization#super_admin] or can_edit from parent define viewer: [group#member] or editor or can_view from parent or member from parent define blocked: [user, group#member] define parent: [user, service, organization, platform] diff --git a/internal/ent/hooks/apitoken.go b/internal/ent/hooks/apitoken.go index 0fa5e21805..a23aa3b5bc 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" @@ -54,7 +56,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 } @@ -88,6 +90,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 @@ -101,23 +121,31 @@ 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 + } - return nil, ErrInternalServerError + 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, ErrInternalServerError + } } } @@ -126,27 +154,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, @@ -155,30 +192,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 b862d1893a..315d9fac07 100644 --- a/internal/ent/hooks/errors.go +++ b/internal/ent/hooks/errors.go @@ -187,6 +187,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 1ebdf40254..aca8155f14 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" @@ -48,7 +49,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 { @@ -161,7 +169,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) } @@ -184,9 +192,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 } @@ -210,7 +218,7 @@ func filterQueryResults[V any](ctx context.Context, query ent.Query, next ent.Qu } } -func skipFilter(ctx context.Context, customSkipperFunc ...skipperFunc) bool { +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 || rule.IsInternalRequest(ctx) { return true @@ -221,6 +229,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) { @@ -275,7 +291,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 } @@ -319,7 +335,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 } @@ -333,22 +349,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 49fa8e3475..3e3128dfc5 100644 --- a/internal/ent/privacy/policy/checks.go +++ b/internal/ent/privacy/policy/checks.go @@ -85,6 +85,13 @@ func CheckOrgReadAccess() privacy.QueryRule { if _, hasAnon := auth.ActiveTrustCenterIDKey.Get(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) @@ -106,6 +113,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) }) @@ -123,6 +136,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) }) } @@ -239,6 +259,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 e97e76ba2d..12739bfd7e 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" @@ -14,6 +15,10 @@ import ( "github.com/theopenlane/core/pkg/logx" ) +const ( + CanCreatePrefix = "can_create_" +) + // CheckGroupBasedObjectCreationAccess is a rule that returns allow decision if user has // access to create the given object in the organization func CheckGroupBasedObjectCreationAccess() privacy.MutationRuleFunc { @@ -22,6 +27,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 + } + } + caller, ok := auth.CallerFromContext(ctx) if !ok || caller == nil || caller.IsAnonymous() { logx.FromContext(ctx).Info().Msg("unable to get caller from context") @@ -34,7 +47,7 @@ func CheckGroupBasedObjectCreationAccess() privacy.MutationRuleFunc { } // get the relation, which will be can_create_ - relation := fmt.Sprintf("can_create_%s", strcase.SnakeCase(m.Type())) + relation := fmt.Sprintf("%s%s", CanCreatePrefix, strcase.SnakeCase(m.Type())) ac := fgax.AccessCheck{ SubjectID: caller.SubjectID, diff --git a/internal/ent/privacy/rule/organization.go b/internal/ent/privacy/rule/organization.go index 479bdf7df7..e8ec8f12c9 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 + } + } + } + caller, ok := auth.CallerFromContext(ctx) if ok && caller != nil && caller.OrganizationID != "" { - 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..45394f3d9c --- /dev/null +++ b/internal/ent/privacy/rule/scopes.go @@ -0,0 +1,153 @@ +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) + } + + caller, ok := auth.CallerFromContext(ctx) + if !ok || caller == nil { + logx.FromContext(ctx).Error().Msg("unable to get caller from context for api token scope check") + + return generated.ErrPermissionDenied + } + + orgID := caller.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: caller.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 86f37926ab..3d55bdc9e6 100644 --- a/internal/ent/schema/mixin_createacess.go +++ b/internal/ent/schema/mixin_createacess.go @@ -10,38 +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", - "identity_holder", - "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 8b3243d603..c531c65711 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 607fa0b813..43db66cb11 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 eef4e943f0..44062125ac 100644 --- a/internal/graphapi/models_test.go +++ b/internal/graphapi/models_test.go @@ -849,14 +849,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 f83fcac1da..53424f932d 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" @@ -143,7 +144,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, diff --git a/internal/graphapi/tools_test.go b/internal/graphapi/tools_test.go index eb55cb9111..d9c56142a6 100644 --- a/internal/graphapi/tools_test.go +++ b/internal/graphapi/tools_test.go @@ -157,6 +157,7 @@ func (suite *GraphTestSuite) SetupSuite(t *testing.T) { "OPENFGA_MAX_CHECKS_PER_BATCH_CHECK": "100", "OPENFGA_CHECK_ITERATOR_CACHE_ENABLED": "false", "OPENFGA_LIST_OBJECTS_ITERATOR_CACHE_ENABLED": "false", + "OPENFGA_MAX_TYPES_PER_AUTHORIZATION_MODEL": "1000", }, ))