Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion cli/cmd/apitokens/create.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
36 changes: 36 additions & 0 deletions cli/cmd/apitokens/scopes.go
Original file line number Diff line number Diff line change
@@ -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
}
259 changes: 259 additions & 0 deletions fga/model/helpers.go
Original file line number Diff line number Diff line change
@@ -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_<verb>_<object> 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_<object> 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
}
47 changes: 47 additions & 0 deletions fga/model/helpers_test.go
Original file line number Diff line number Diff line change
@@ -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"))
}
Loading