diff --git a/ee/authz/openfgaschema/base.fga b/ee/authz/openfgaschema/base.fga new file mode 100644 index 000000000000..17cbaec7d87d --- /dev/null +++ b/ee/authz/openfgaschema/base.fga @@ -0,0 +1,44 @@ +module base + +type organisation + relations + define read: [user, role#assignee] + define update: [user, role#assignee] + +type user + relations + define read: [user, role#assignee] + define update: [user, role#assignee] + define delete: [user, role#assignee] + +type anonymous + +type role + relations + define assignee: [user] + + define read: [user, role#assignee] + define update: [user, role#assignee] + define delete: [user, role#assignee] + +type resources + relations + define create: [user, role#assignee] + define list: [user, role#assignee] + + define read: [user, role#assignee] + define update: [user, role#assignee] + define delete: [user, role#assignee] + +type resource + relations + define read: [user, anonymous, role#assignee] + define update: [user, role#assignee] + define delete: [user, role#assignee] + + define block: [user, role#assignee] + + +type telemetry + relations + define read: [user, anonymous, role#assignee] diff --git a/ee/authz/openfgaschema/schema.go b/ee/authz/openfgaschema/schema.go new file mode 100644 index 000000000000..605cad0501f1 --- /dev/null +++ b/ee/authz/openfgaschema/schema.go @@ -0,0 +1,29 @@ +package openfgaschema + +import ( + "context" + _ "embed" + + "github.com/SigNoz/signoz/pkg/authz" + openfgapkgtransformer "github.com/openfga/language/pkg/go/transformer" +) + +var ( + //go:embed base.fga + baseDSL string +) + +type schema struct{} + +func NewSchema() authz.Schema { + return &schema{} +} + +func (schema *schema) Get(ctx context.Context) []openfgapkgtransformer.ModuleFile { + return []openfgapkgtransformer.ModuleFile{ + { + Name: "base.fga", + Contents: baseDSL, + }, + } +} diff --git a/ee/http/middleware/authz.go b/ee/http/middleware/authz.go new file mode 100644 index 000000000000..c6b20eda39c1 --- /dev/null +++ b/ee/http/middleware/authz.go @@ -0,0 +1,132 @@ +package middleware + +import ( + "log/slog" + "net/http" + + "github.com/SigNoz/signoz/pkg/authz" + "github.com/SigNoz/signoz/pkg/http/render" + "github.com/SigNoz/signoz/pkg/types/authtypes" + "github.com/gorilla/mux" +) + +const ( + authzDeniedMessage string = "::AUTHZ-DENIED::" +) + +type AuthZ struct { + logger *slog.Logger + authzService authz.AuthZ +} + +func NewAuthZ(logger *slog.Logger) *AuthZ { + if logger == nil { + panic("cannot build authz middleware, logger is empty") + } + + return &AuthZ{logger: logger} +} + +func (middleware *AuthZ) ViewAccess(next http.HandlerFunc) http.HandlerFunc { + return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + claims, err := authtypes.ClaimsFromContext(req.Context()) + if err != nil { + render.Error(rw, err) + return + } + + if err := claims.IsViewer(); err != nil { + middleware.logger.WarnContext(req.Context(), authzDeniedMessage, "claims", claims) + render.Error(rw, err) + return + } + + next(rw, req) + }) +} + +func (middleware *AuthZ) EditAccess(next http.HandlerFunc) http.HandlerFunc { + return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + claims, err := authtypes.ClaimsFromContext(req.Context()) + if err != nil { + render.Error(rw, err) + return + } + + if err := claims.IsEditor(); err != nil { + middleware.logger.WarnContext(req.Context(), authzDeniedMessage, "claims", claims) + render.Error(rw, err) + return + } + + next(rw, req) + }) +} + +func (middleware *AuthZ) AdminAccess(next http.HandlerFunc) http.HandlerFunc { + return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + claims, err := authtypes.ClaimsFromContext(req.Context()) + if err != nil { + render.Error(rw, err) + return + } + + if err := claims.IsAdmin(); err != nil { + middleware.logger.WarnContext(req.Context(), authzDeniedMessage, "claims", claims) + render.Error(rw, err) + return + } + + next(rw, req) + }) +} + +func (middleware *AuthZ) SelfAccess(next http.HandlerFunc) http.HandlerFunc { + return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + claims, err := authtypes.ClaimsFromContext(req.Context()) + if err != nil { + render.Error(rw, err) + return + } + + id := mux.Vars(req)["id"] + if err := claims.IsSelfAccess(id); err != nil { + middleware.logger.WarnContext(req.Context(), authzDeniedMessage, "claims", claims) + render.Error(rw, err) + return + } + + next(rw, req) + }) +} + +func (middleware *AuthZ) OpenAccess(next http.HandlerFunc) http.HandlerFunc { + return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + next(rw, req) + }) +} + +// Check middleware accepts the relation, typeable, parentTypeable (for direct access + group relations) and a callback function to derive selector and parentSelectors on per request basis. +func (middleware *AuthZ) Check(next http.HandlerFunc, relation authtypes.Relation, translation authtypes.Relation, typeable authtypes.Typeable, parentTypeable authtypes.Typeable, cb authtypes.SelectorCallbackFn) http.HandlerFunc { + return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + claims, err := authtypes.ClaimsFromContext(req.Context()) + if err != nil { + render.Error(rw, err) + return + } + + selector, parentSelectors, err := cb(req) + if err != nil { + render.Error(rw, err) + return + } + + err = middleware.authzService.CheckWithTupleCreation(req.Context(), claims, relation, typeable, selector, parentTypeable, parentSelectors...) + if err != nil { + render.Error(rw, err) + return + } + + next(rw, req) + }) +} diff --git a/pkg/authz/authz.go b/pkg/authz/authz.go index 52eb59392def..26be6a9d39d4 100644 --- a/pkg/authz/authz.go +++ b/pkg/authz/authz.go @@ -4,6 +4,7 @@ import ( "context" "github.com/SigNoz/signoz/pkg/factory" + "github.com/SigNoz/signoz/pkg/types/authtypes" openfgav1 "github.com/openfga/api/proto/openfga/v1" ) @@ -12,4 +13,7 @@ type AuthZ interface { // Check returns error when the upstream authorization server is unavailable or the subject (s) doesn't have relation (r) on object (o). Check(context.Context, *openfgav1.CheckRequestTupleKey) error + + // CheckWithTupleCreation takes upon the responsibility for generating the tuples alongside everything Check does. + CheckWithTupleCreation(context.Context, authtypes.Claims, authtypes.Relation, authtypes.Typeable, authtypes.Selector, authtypes.Typeable, ...authtypes.Selector) error } diff --git a/pkg/authz/openfgaauthz/provider.go b/pkg/authz/openfgaauthz/provider.go index bc66d5a41105..8903656ef5ac 100644 --- a/pkg/authz/openfgaauthz/provider.go +++ b/pkg/authz/openfgaauthz/provider.go @@ -194,3 +194,40 @@ func (provider *provider) Check(ctx context.Context, tupleReq *openfgav1.CheckRe return nil } + +func (provider *provider) CheckWithTupleCreation(ctx context.Context, claims authtypes.Claims, relation authtypes.Relation, typeable authtypes.Typeable, selector authtypes.Selector, parentTypeable authtypes.Typeable, parentSelectors ...authtypes.Selector) error { + subject, err := authtypes.NewSubject(authtypes.TypeUser, claims.UserID, authtypes.Relation{}) + if err != nil { + return err + } + + tuples, err := typeable.Tuples(subject, relation, selector, parentTypeable, parentSelectors...) + if err != nil { + return err + } + + check, err := provider.sequentialCheck(ctx, tuples) + if err != nil { + return err + } + if !check { + return errors.Newf(errors.TypeForbidden, authtypes.ErrCodeAuthZForbidden, "subject %s cannot %s object %s", subject, relation.StringValue(), typeable.Type().StringValue()) + } + + return nil +} + +func (provider *provider) sequentialCheck(ctx context.Context, tuplesReq []*openfgav1.CheckRequestTupleKey) (bool, error) { + for _, tupleReq := range tuplesReq { + err := provider.Check(ctx, tupleReq) + if err == nil { + return true, nil + } + if errors.Ast(err, errors.TypeInternal) { + // return at the first internal error as the evaluation will be incorrect + return false, err + } + } + + return false, nil +} diff --git a/pkg/authz/openfgaschema/base.fga b/pkg/authz/openfgaschema/base.fga index 7275c3137c59..192009b751a7 100644 --- a/pkg/authz/openfgaschema/base.fga +++ b/pkg/authz/openfgaschema/base.fga @@ -8,6 +8,7 @@ type role type organisation relations - define admin: [role#assignee] - define editor: [role#assignee] or admin - define viewer: [role#assignee] or editor \ No newline at end of file + define create: [role#assignee] + define read: [role#assignee] + define update: [role#assignee] + define delete: [role#assignee] \ No newline at end of file diff --git a/pkg/http/middleware/authz.go b/pkg/http/middleware/authz.go index 63dd785ce983..6abd9aeaecbc 100644 --- a/pkg/http/middleware/authz.go +++ b/pkg/http/middleware/authz.go @@ -106,11 +106,15 @@ func (middleware *AuthZ) OpenAccess(next http.HandlerFunc) http.HandlerFunc { }) } -// each individual APIs should be responsible for defining the relation and the object being accessed, subject will be derived from the request -func (middleware *AuthZ) Check(next http.HandlerFunc, relation string) http.HandlerFunc { +func (middleware *AuthZ) Check(next http.HandlerFunc, _ authtypes.Relation, translation authtypes.Relation, _ authtypes.Typeable, _ authtypes.Typeable, _ authtypes.SelectorCallbackFn) http.HandlerFunc { return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { - checkRequestTupleKey := authtypes.NewTuple("", "", "") - err := middleware.authzService.Check(req.Context(), checkRequestTupleKey) + claims, err := authtypes.ClaimsFromContext(req.Context()) + if err != nil { + render.Error(rw, err) + return + } + + err = middleware.authzService.CheckWithTupleCreation(req.Context(), claims, translation, authtypes.TypeableOrganization, authtypes.MustNewSelector(authtypes.TypeOrganization, claims.OrgID), nil) if err != nil { render.Error(rw, err) return diff --git a/pkg/types/authtypes/name.go b/pkg/types/authtypes/name.go new file mode 100644 index 000000000000..32994f54f568 --- /dev/null +++ b/pkg/types/authtypes/name.go @@ -0,0 +1,27 @@ +package authtypes + +import ( + "regexp" + + "github.com/SigNoz/signoz/pkg/errors" +) + +var ( + nameRegex = regexp.MustCompile("^[a-z]{1,35}$") +) + +type Name struct { + val string +} + +func MustNewName(name string) Name { + if !nameRegex.MatchString(name) { + panic(errors.NewInternalf(errors.CodeInternal, "name must conform to regex %s", nameRegex.String())) + } + + return Name{val: name} +} + +func (name Name) String() string { + return name.val +} diff --git a/pkg/types/authtypes/organization.go b/pkg/types/authtypes/organization.go new file mode 100644 index 000000000000..d1510cdf62c5 --- /dev/null +++ b/pkg/types/authtypes/organization.go @@ -0,0 +1,23 @@ +package authtypes + +import ( + "strings" + + openfgav1 "github.com/openfga/api/proto/openfga/v1" +) + +var _ Typeable = new(organization) + +type organization struct{} + +func (organization *organization) Tuples(subject string, relation Relation, selector Selector, parentTypeable Typeable, parentSelectors ...Selector) ([]*openfgav1.CheckRequestTupleKey, error) { + tuples := make([]*openfgav1.CheckRequestTupleKey, 0) + object := strings.Join([]string{TypeRole.StringValue(), selector.String()}, ":") + tuples = append(tuples, &openfgav1.CheckRequestTupleKey{User: subject, Relation: relation.StringValue(), Object: object}) + + return tuples, nil +} + +func (organization *organization) Type() Type { + return TypeOrganization +} diff --git a/pkg/types/authtypes/relation.go b/pkg/types/authtypes/relation.go new file mode 100644 index 000000000000..e0c1b8236d1c --- /dev/null +++ b/pkg/types/authtypes/relation.go @@ -0,0 +1,23 @@ +package authtypes + +import "github.com/SigNoz/signoz/pkg/valuer" + +var ( + RelationCreate = Relation{valuer.NewString("create")} + RelationRead = Relation{valuer.NewString("read")} + RelationUpdate = Relation{valuer.NewString("update")} + RelationDelete = Relation{valuer.NewString("delete")} + RelationList = Relation{valuer.NewString("list")} + RelationBlock = Relation{valuer.NewString("block")} + RelationAssignee = Relation{valuer.NewString("assignee")} +) + +var ( + TypeUserSupportedRelations = []Relation{RelationRead, RelationUpdate, RelationDelete} + TypeRoleSupportedRelations = []Relation{RelationAssignee, RelationRead, RelationUpdate, RelationDelete} + TypeOrganizationSupportedRelations = []Relation{RelationCreate, RelationRead, RelationUpdate, RelationDelete, RelationList} + TypeResourceSupportedRelations = []Relation{RelationRead, RelationUpdate, RelationDelete, RelationBlock} + TypeResourcesSupportedRelations = []Relation{RelationCreate, RelationRead, RelationUpdate, RelationDelete, RelationList} +) + +type Relation struct{ valuer.String } diff --git a/pkg/types/authtypes/resource.go b/pkg/types/authtypes/resource.go new file mode 100644 index 000000000000..0bbfc5f9c28d --- /dev/null +++ b/pkg/types/authtypes/resource.go @@ -0,0 +1,37 @@ +package authtypes + +import ( + "strings" + + openfgav1 "github.com/openfga/api/proto/openfga/v1" +) + +var _ Typeable = new(resource) + +type resource struct { + name Name +} + +func MustNewResource(name string) Typeable { + return &resource{name: MustNewName(name)} +} + +func (resource *resource) Tuples(subject string, relation Relation, selector Selector, parentTypeable Typeable, parentSelectors ...Selector) ([]*openfgav1.CheckRequestTupleKey, error) { + tuples := make([]*openfgav1.CheckRequestTupleKey, 0) + for _, selector := range parentSelectors { + resourcesTuples, err := parentTypeable.Tuples(subject, relation, selector, nil) + if err != nil { + return nil, err + } + tuples = append(tuples, resourcesTuples...) + } + + object := strings.Join([]string{TypeResource.StringValue(), resource.name.String(), selector.String()}, ":") + tuples = append(tuples, &openfgav1.CheckRequestTupleKey{User: subject, Relation: relation.StringValue(), Object: object}) + + return tuples, nil +} + +func (resource *resource) Type() Type { + return TypeResource +} diff --git a/pkg/types/authtypes/resources.go b/pkg/types/authtypes/resources.go new file mode 100644 index 000000000000..6213b5dd8e09 --- /dev/null +++ b/pkg/types/authtypes/resources.go @@ -0,0 +1,26 @@ +package authtypes + +import ( + "strings" + + openfgav1 "github.com/openfga/api/proto/openfga/v1" +) + +var _ Typeable = new(resources) + +type resources struct { + name Name +} + +func MustNewResources(name string) Typeable { + return &resources{name: MustNewName(name)} +} + +func (resources *resources) Tuples(subject string, relation Relation, selector Selector, _ Typeable, _ ...Selector) ([]*openfgav1.CheckRequestTupleKey, error) { + object := strings.Join([]string{TypeResources.StringValue(), resources.name.String(), selector.String()}, ":") + return []*openfgav1.CheckRequestTupleKey{{User: subject, Relation: relation.StringValue(), Object: object}}, nil +} + +func (resources *resources) Type() Type { + return TypeResources +} diff --git a/pkg/types/authtypes/role.go b/pkg/types/authtypes/role.go new file mode 100644 index 000000000000..9467adb193f9 --- /dev/null +++ b/pkg/types/authtypes/role.go @@ -0,0 +1,31 @@ +package authtypes + +import ( + "strings" + + openfgav1 "github.com/openfga/api/proto/openfga/v1" +) + +var _ Typeable = new(role) + +type role struct{} + +func (role *role) Tuples(subject string, relation Relation, selector Selector, parentTypeable Typeable, parentSelectors ...Selector) ([]*openfgav1.CheckRequestTupleKey, error) { + tuples := make([]*openfgav1.CheckRequestTupleKey, 0) + for _, selector := range parentSelectors { + resourcesTuples, err := parentTypeable.Tuples(subject, relation, selector, nil) + if err != nil { + return nil, err + } + tuples = append(tuples, resourcesTuples...) + } + + object := strings.Join([]string{TypeRole.StringValue(), selector.String()}, ":") + tuples = append(tuples, &openfgav1.CheckRequestTupleKey{User: subject, Relation: relation.StringValue(), Object: object}) + + return tuples, nil +} + +func (role *role) Type() Type { + return TypeRole +} diff --git a/pkg/types/authtypes/selector.go b/pkg/types/authtypes/selector.go new file mode 100644 index 000000000000..1480dbdaca1f --- /dev/null +++ b/pkg/types/authtypes/selector.go @@ -0,0 +1,62 @@ +package authtypes + +import ( + "net/http" + "regexp" + + "github.com/SigNoz/signoz/pkg/errors" +) + +var ( + typeUserSelectorRegex = regexp.MustCompile(`^[0-9a-f]{8}(?:\-[0-9a-f]{4}){3}-[0-9a-f]{12}$`) + typeRoleSelectorRegex = regexp.MustCompile(`^[0-9a-f]{8}(?:\-[0-9a-f]{4}){3}-[0-9a-f]{12}$`) + typeOrganizationSelectorRegex = regexp.MustCompile(`^[0-9a-f]{8}(?:\-[0-9a-f]{4}){3}-[0-9a-f]{12}$`) + typeResourceSelectorRegex = regexp.MustCompile(`^[0-9a-f]{8}(?:\-[0-9a-f]{4}){3}-[0-9a-f]{12}$`) + typeResourcesSelectorRegex = regexp.MustCompile(`^org:[0-9a-f]{8}(?:\-[0-9a-f]{4}){3}-[0-9a-f]{12}$`) +) + +type SelectorCallbackFn func(*http.Request) (Selector, []Selector, error) + +type Selector struct { + val string +} + +func NewSelector(typed Type, selector string) (Selector, error) { + switch typed { + case TypeUser: + if !typeUserSelectorRegex.MatchString(selector) { + return Selector{}, errors.Newf(errors.TypeInvalidInput, ErrCodeAuthZInvalidSelectorRegex, "selector must conform to regex %s", typeUserSelectorRegex.String()) + } + case TypeRole: + if !typeRoleSelectorRegex.MatchString(selector) { + return Selector{}, errors.Newf(errors.TypeInvalidInput, ErrCodeAuthZInvalidSelectorRegex, "selector must conform to regex %s", typeRoleSelectorRegex.String()) + } + case TypeOrganization: + if !typeOrganizationSelectorRegex.MatchString(selector) { + return Selector{}, errors.Newf(errors.TypeInvalidInput, ErrCodeAuthZInvalidSelectorRegex, "selector must conform to regex %s", typeOrganizationSelectorRegex.String()) + } + case TypeResource: + if !typeResourceSelectorRegex.MatchString(selector) { + return Selector{}, errors.Newf(errors.TypeInvalidInput, ErrCodeAuthZInvalidSelectorRegex, "selector must conform to regex %s", typeResourceSelectorRegex.String()) + } + case TypeResources: + if !typeResourcesSelectorRegex.MatchString(selector) { + return Selector{}, errors.Newf(errors.TypeInvalidInput, ErrCodeAuthZInvalidSelectorRegex, "selector must conform to regex %s", typeResourcesSelectorRegex.String()) + } + } + + return Selector{val: selector}, nil +} + +func MustNewSelector(typed Type, input string) Selector { + selector, err := NewSelector(typed, input) + if err != nil { + panic(err) + } + + return selector +} + +func (selector Selector) String() string { + return selector.val +} diff --git a/pkg/types/authtypes/subject.go b/pkg/types/authtypes/subject.go new file mode 100644 index 000000000000..79ace130758a --- /dev/null +++ b/pkg/types/authtypes/subject.go @@ -0,0 +1,18 @@ +package authtypes + +func NewSubject(subjectType Type, selector string, relation Relation) (string, error) { + if relation.IsZero() { + return subjectType.StringValue() + ":" + selector, nil + } + + return subjectType.StringValue() + ":" + selector + "#" + relation.StringValue(), nil +} + +func MustNewSubject(subjectType Type, selector string, relation Relation) string { + subject, err := NewSubject(subjectType, selector, relation) + if err != nil { + panic(err) + } + + return subject +} diff --git a/pkg/types/authtypes/tuple.go b/pkg/types/authtypes/tuple.go deleted file mode 100644 index 7911954b45d7..000000000000 --- a/pkg/types/authtypes/tuple.go +++ /dev/null @@ -1,15 +0,0 @@ -package authtypes - -import ( - "github.com/SigNoz/signoz/pkg/errors" - openfgav1 "github.com/openfga/api/proto/openfga/v1" -) - -var ( - ErrCodeAuthZUnavailable = errors.MustNewCode("authz_unavailable") - ErrCodeAuthZForbidden = errors.MustNewCode("authz_forbidden") -) - -func NewTuple(subject string, relation string, object string) *openfgav1.CheckRequestTupleKey { - return &openfgav1.CheckRequestTupleKey{User: subject, Relation: relation, Object: object} -} diff --git a/pkg/types/authtypes/typeable.go b/pkg/types/authtypes/typeable.go new file mode 100644 index 000000000000..2b07b2613710 --- /dev/null +++ b/pkg/types/authtypes/typeable.go @@ -0,0 +1,36 @@ +package authtypes + +import ( + "github.com/SigNoz/signoz/pkg/errors" + "github.com/SigNoz/signoz/pkg/valuer" + openfgav1 "github.com/openfga/api/proto/openfga/v1" +) + +var ( + ErrCodeAuthZUnavailable = errors.MustNewCode("authz_unavailable") + ErrCodeAuthZForbidden = errors.MustNewCode("authz_forbidden") + ErrCodeAuthZInvalidSelectorRegex = errors.MustNewCode("authz_invalid_selector_regex") + ErrCodeAuthZUnsupportedRelation = errors.MustNewCode("authz_unsupported_relation") + ErrCodeAuthZInvalidSubject = errors.MustNewCode("authz_invalid_subject") +) + +var ( + TypeUser = Type{valuer.NewString("user")} + TypeRole = Type{valuer.NewString("role")} + TypeOrganization = Type{valuer.NewString("organization")} + TypeResource = Type{valuer.NewString("resource")} + TypeResources = Type{valuer.NewString("resources")} +) + +var ( + TypeableUser = &user{} + TypeableRole = &role{} + TypeableOrganization = &organization{} +) + +type Typeable interface { + Type() Type + Tuples(subject string, relation Relation, selector Selector, parentType Typeable, parentSelectors ...Selector) ([]*openfgav1.CheckRequestTupleKey, error) +} + +type Type struct{ valuer.String } diff --git a/pkg/types/authtypes/user.go b/pkg/types/authtypes/user.go new file mode 100644 index 000000000000..9de8fd0c2cb6 --- /dev/null +++ b/pkg/types/authtypes/user.go @@ -0,0 +1,31 @@ +package authtypes + +import ( + "strings" + + openfgav1 "github.com/openfga/api/proto/openfga/v1" +) + +var _ Typeable = new(user) + +type user struct{} + +func (user *user) Tuples(subject string, relation Relation, selector Selector, parentTypeable Typeable, parentSelectors ...Selector) ([]*openfgav1.CheckRequestTupleKey, error) { + tuples := make([]*openfgav1.CheckRequestTupleKey, 0) + for _, selector := range parentSelectors { + resourcesTuples, err := parentTypeable.Tuples(subject, relation, selector, nil) + if err != nil { + return nil, err + } + tuples = append(tuples, resourcesTuples...) + } + + object := strings.Join([]string{TypeUser.StringValue(), selector.String()}, ":") + tuples = append(tuples, &openfgav1.CheckRequestTupleKey{User: subject, Relation: relation.StringValue(), Object: object}) + + return tuples, nil +} + +func (user *user) Type() Type { + return TypeUser +}