feat(authz): build authz service (#9064)

* feat(authz): define the domain layer

* feat(authz): added openfga schema and split the enterprise code

* feat(authz): revert http handler

* feat(authz): address comments

* feat(authz): address comments

* feat(authz): typo comments

* feat(authz): address review comments

* feat(authz): address review comments

* feat(authz): update the oss model

* feat(authz): update the sequential check
This commit is contained in:
Vikrant Gupta 2025-09-17 21:35:11 +05:30 committed by GitHub
parent 24307b48ff
commit 0c25de9560
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 572 additions and 22 deletions

View File

@ -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]

View File

@ -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,
},
}
}

132
ee/http/middleware/authz.go Normal file
View File

@ -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)
})
}

View File

@ -4,6 +4,7 @@ import (
"context" "context"
"github.com/SigNoz/signoz/pkg/factory" "github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/types/authtypes"
openfgav1 "github.com/openfga/api/proto/openfga/v1" 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 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 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
} }

View File

@ -194,3 +194,40 @@ func (provider *provider) Check(ctx context.Context, tupleReq *openfgav1.CheckRe
return nil 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
}

View File

@ -8,6 +8,7 @@ type role
type organisation type organisation
relations relations
define admin: [role#assignee] define create: [role#assignee]
define editor: [role#assignee] or admin define read: [role#assignee]
define viewer: [role#assignee] or editor define update: [role#assignee]
define delete: [role#assignee]

View File

@ -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, _ authtypes.Relation, translation authtypes.Relation, _ authtypes.Typeable, _ authtypes.Typeable, _ authtypes.SelectorCallbackFn) http.HandlerFunc {
func (middleware *AuthZ) Check(next http.HandlerFunc, relation string) http.HandlerFunc {
return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
checkRequestTupleKey := authtypes.NewTuple("", "", "") claims, err := authtypes.ClaimsFromContext(req.Context())
err := middleware.authzService.Check(req.Context(), checkRequestTupleKey) 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 { if err != nil {
render.Error(rw, err) render.Error(rw, err)
return return

View File

@ -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
}

View File

@ -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
}

View File

@ -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 }

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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}
}

View File

@ -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 }

View File

@ -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
}