Movatterモバイル変換


[0]ホーム

URL:


Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Sign up
Appearance settings

feat: add scope enforcement metrics to RBAC authorizer#19991

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to ourterms of service andprivacy statement. We’ll occasionally send you account related emails.

Already on GitHub?Sign in to your account

Draft
ThomasK33 wants to merge1 commit intothomask33/09-26-add_token_scope_support_in_cli
base:thomask33/09-26-add_token_scope_support_in_cli
Choose a base branch
Loading
fromthomask33/09-26-add_detailed_scope_auth_metrics
Draft
Show file tree
Hide file tree
Changes fromall 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
235 changes: 214 additions & 21 deletionscoderd/rbac/authz.go
View file
Open in desktop
Original file line numberDiff line numberDiff line change
Expand Up@@ -33,6 +33,15 @@ type AuthCall struct {
Object Object
}

type scopeDecision struct {
allow bool
scopeAllow bool
scopeAllowList bool
roleAllow bool
aclAllow bool
metricsErr error
}

// hashAuthorizeCall guarantees a unique hash for a given auth call.
// If two hashes are equal, then the result of a given authorize() call
// will be the same.
Expand DownExpand Up@@ -255,11 +264,15 @@ func Filter[O Objecter](ctx context.Context, auth Authorizer, subject Subject, a

// RegoAuthorizer will use a prepared rego query for performing authorize()
type RegoAuthorizer struct {
query rego.PreparedEvalQuery
partialQuery rego.PreparedPartialQuery
query rego.PreparedEvalQuery
partialQuery rego.PreparedPartialQuery
scopeMetricsQuery rego.PreparedEvalQuery

authorizeHist *prometheus.HistogramVec
prepareHist prometheus.Histogram
authorizeHist *prometheus.HistogramVec
prepareHist prometheus.Histogram
scopeDecisionCounter *prometheus.CounterVec
scopeDecisionDuration *prometheus.HistogramVec
scopeAllowListMisses *prometheus.CounterVec

// strict checking also verifies the inputs to the authorizer. Making sure
// the action make sense for the input object.
Expand All@@ -272,10 +285,11 @@ var (
// Load the policy from policy.rego in this directory.
//
//go:embed policy.rego
regoPolicy string
queryOnce sync.Once
query rego.PreparedEvalQuery
partialQuery rego.PreparedPartialQuery
regoPolicy string
queryOnce sync.Once
query rego.PreparedEvalQuery
partialQuery rego.PreparedPartialQuery
scopeMetricsQuery rego.PreparedEvalQuery
)

// NewCachingAuthorizer returns a new RegoAuthorizer that supports context based
Expand DownExpand Up@@ -317,6 +331,14 @@ func NewAuthorizer(registry prometheus.Registerer) *RegoAuthorizer {
if err != nil {
panic(xerrors.Errorf("compile partial rego: %w", err))
}

scopeMetricsQuery, err = rego.New(
rego.Query("data.authz.scope_metrics"),
rego.Module("policy.rego", regoPolicy),
).PrepareForEval(context.Background())
if err != nil {
panic(xerrors.Errorf("compile scope metrics rego: %w", err))
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others.Learn more.

Do we have to panic here? Just for consistency with the rest?

}
})

// Register metrics to prometheus.
Expand DownExpand Up@@ -357,12 +379,38 @@ func NewAuthorizer(registry prometheus.Registerer) *RegoAuthorizer {
Buckets: buckets,
})

return &RegoAuthorizer{
query: query,
partialQuery: partialQuery,
scopeDecisionCounter := factory.NewCounterVec(prometheus.CounterOpts{
Namespace: "coderd",
Subsystem: "authz",
Name: "scope_enforcement_total",
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others.Learn more.

Nit: "enforcements"

Help: "Scope evaluation outcomes keyed by decision, scope, and resource.",
}, []string{"decision", "scope", "resource", "outcome"})
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others.Learn more.

Do you have an estimate for the cardinality?

decision: 6
resource: max 41 ($ grep Type: coderd/rbac/object_gen.go | wc -l)
scope: seems unbounded?
outcome: 2


authorizeHist: authorizeHistogram,
prepareHist: prepareHistogram,
scopeDecisionDuration := factory.NewHistogramVec(prometheus.HistogramOpts{
Namespace: "coderd",
Subsystem: "authz",
Name: "scope_enforcement_duration_seconds",
Help: "Duration of scope enforcement decisions in seconds.",
Buckets: buckets,
}, []string{"decision", "scope", "resource", "outcome"})
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others.Learn more.

Do we expect this to vary measurably by resource/scope?


scopeAllowListMisses := factory.NewCounterVec(prometheus.CounterOpts{
Namespace: "coderd",
Subsystem: "authz",
Name: "scope_allowlist_miss_total",
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others.Learn more.

Nit: "misses"

Help: "Requests denied because a scope allow-list did not include the resource.",
}, []string{"scope", "resource"})

return &RegoAuthorizer{
query: query,
partialQuery: partialQuery,
scopeMetricsQuery: scopeMetricsQuery,

authorizeHist: authorizeHistogram,
prepareHist: prepareHistogram,
scopeDecisionCounter: scopeDecisionCounter,
scopeDecisionDuration: scopeDecisionDuration,
scopeAllowListMisses: scopeAllowListMisses,
}
}

Expand DownExpand Up@@ -396,11 +444,12 @@ func (a RegoAuthorizer) Authorize(ctx context.Context, subject Subject, action p
)
defer span.End()

err := a.authorize(ctx, subject, action, object)
decision,err := a.authorize(ctx, subject, action, object)
authorized := err == nil
span.SetAttributes(attribute.Bool("authorized", authorized))

dur := time.Since(start)
a.observeScopeMetrics(decision, subject.Scope, object, dur)
if !authorized {
a.authorizeHist.WithLabelValues("false").Observe(dur.Seconds())
return err
Expand All@@ -414,38 +463,182 @@ func (a RegoAuthorizer) Authorize(ctx context.Context, subject Subject, action p
// It is a different function so the exported one can add tracing + metrics.
// That code tends to clutter up the actual logic, so it's separated out.
// nolint:revive
func (a RegoAuthorizer) authorize(ctx context.Context, subject Subject, action policy.Action, object Object) error {
func (a RegoAuthorizer) authorize(ctx context.Context, subject Subject, action policy.Action, object Object) (scopeDecision, error) {
decision := scopeDecision{}
if subject.Roles == nil {
return xerrors.Errorf("subject must have roles")
returndecision,xerrors.Errorf("subject must have roles")
}
if subject.Scope == nil {
return xerrors.Errorf("subject must have a scope")
returndecision,xerrors.Errorf("subject must have a scope")
}

// The caller should use either 1 or the other (or none).
// Using "AnyOrgOwner" and an OrgID is a contradiction.
// An empty uuid or a nil uuid means "no org owner".
if object.AnyOrgOwner && !(object.OrgID == "" || object.OrgID == "00000000-0000-0000-0000-000000000000") {
return xerrors.Errorf("object cannot have 'any_org' and an 'org_id' specified, values are mutually exclusive")
returndecision,xerrors.Errorf("object cannot have 'any_org' and an 'org_id' specified, values are mutually exclusive")
}

astV, err := regoInputValue(subject, action, object)
if err != nil {
return xerrors.Errorf("convert input to value: %w", err)
return decision, xerrors.Errorf("convert input to value: %w", err)
}

metricsResults, metricsErr := a.scopeMetricsQuery.Eval(ctx, rego.EvalParsedInput(astV))
if metricsErr != nil {
decision.metricsErr = correctCancelError(metricsErr)
}

results, err := a.query.Eval(ctx, rego.EvalParsedInput(astV))
if err != nil {
err = correctCancelError(err)
return xerrors.Errorf("evaluate rego: %w", err)
return decision, xerrors.Errorf("evaluate rego: %w", err)
}

decision.allow = results.Allowed()
if decision.metricsErr == nil {
if err := applyScopeMetrics(&decision, metricsResults); err != nil {
decision.metricsErr = err
}
}

if !results.Allowed() {
return ForbiddenWithInternal(xerrors.Errorf("policy disallows request"), subject, action, object, results)
return decision, ForbiddenWithInternal(xerrors.Errorf("policy disallows request"), subject, action, object, results)
}
return decision, nil
}

func applyScopeMetrics(decision *scopeDecision, results rego.ResultSet) error {
if len(results) == 0 {
return xerrors.Errorf("scope metrics returned no results")
}
if len(results[0].Expressions) == 0 {
return xerrors.Errorf("scope metrics returned no expressions")
}
metricsMap, ok := results[0].Expressions[0].Value.(map[string]interface{})
if !ok {
return xerrors.Errorf("scope metrics expression unexpected type %T", results[0].Expressions[0].Value)
}

boolVal := func(key string) (bool, error) {
v, ok := metricsMap[key]
if !ok {
return false, xerrors.Errorf("scope metrics missing key %q", key)
}
val, ok := v.(bool)
if !ok {
return false, xerrors.Errorf("scope metrics key %q has unexpected type %T", key, v)
}
return val, nil
}

var err error
if decision.allow, err = boolVal("allow"); err != nil {
return err
}
if decision.scopeAllow, err = boolVal("scope_allow"); err != nil {
return err
}
if decision.scopeAllowList, err = boolVal("scope_allow_list"); err != nil {
return err
}
if decision.roleAllow, err = boolVal("role_allow"); err != nil {
return err
}
if decision.aclAllow, err = boolVal("acl_allow"); err != nil {
return err
}
return nil
}

var scopeLabelReplacer = strings.NewReplacer(
" ", "_",
":", "_",
"*", "all",
"-", "_",
".", "_",
"[", "_",
"]", "_",
"+", "_",
"=", "_",
"/", "_",
"|", "_",
)

func sanitizeScopeLabel(scope ExpandableScope) string {
if scope == nil {
return "none"
}
identifier := scope.Name()
raw := identifier.Name
if raw == "" {
raw = identifier.String()
}
if raw == "" {
return "unknown"
}
if strings.HasPrefix(raw, "scopes[") && strings.HasSuffix(raw, "]") {
inner := strings.TrimSuffix(strings.TrimPrefix(raw, "scopes["), "]")
if inner == "" {
return "multi(0)"
}
count := strings.Count(inner, "+") + 1
return fmt.Sprintf("multi(%d)", count)
}
sanitized := strings.ToLower(scopeLabelReplacer.Replace(strings.TrimSpace(raw)))
if sanitized == "" {
sanitized = "unknown"
}
if len(sanitized) > 63 {
sanitized = sanitized[:63]
}
return sanitized
}

func classifyScopeDecision(decision scopeDecision) string {
if decision.metricsErr != nil {
return "unknown"
}
if decision.scopeAllow {
if decision.allow {
return "scope_allow"
}
if !decision.roleAllow && !decision.aclAllow {
return "role_deny"
}
return "other"
}
if !decision.scopeAllowList {
return "allow_list_deny"
}
return "scope_deny"
}

func (a RegoAuthorizer) observeScopeMetrics(decision scopeDecision, scope ExpandableScope, object Object, dur time.Duration) {
if a.scopeDecisionCounter == nil || a.scopeDecisionDuration == nil || a.scopeAllowListMisses == nil {
return
}
if decision.metricsErr != nil {
return
}
scopeLabel := sanitizeScopeLabel(scope)
resource := object.Type
if resource == "" {
resource = "unknown"
}
outcome := "deny"
if decision.allow {
outcome = "allow"
}
decisionLabel := classifyScopeDecision(decision)

a.scopeDecisionCounter.WithLabelValues(decisionLabel, scopeLabel, resource, outcome).Inc()
a.scopeDecisionDuration.WithLabelValues(decisionLabel, scopeLabel, resource, outcome).Observe(dur.Seconds())
if !decision.scopeAllowList {
a.scopeAllowListMisses.WithLabelValues(scopeLabel, resource).Inc()
}
}

// Prepare will partially execute the rego policy leaving the object fields unknown (except for the type).
// This will vastly speed up performance if batch authorization on the same type of objects is needed.
func (a RegoAuthorizer) Prepare(ctx context.Context, subject Subject, action policy.Action, objectType string) (PreparedAuthorized, error) {
Expand Down
Loading
Loading

[8]ページ先頭

©2009-2025 Movatter.jp