mirror of
https://github.com/coder/coder.git
synced 2026-09-22 05:05:20 +08:00
`POST /api/v2/authcheck` evaluated every check with a full policy evaluation in a serial loop. A subject in many organizations (100+) produced hundreds of full evaluations, taking seconds on a cold cache (DEVEX-608). Group the checks by `(action, resource type)` and authorize each group with the existing `rbac.Filter`, which amortizes a single partial evaluation across the group once it is large enough. Each check is wrapped in a small value struct that carries its response key, so `Filter`'s returned subset maps back to keys by reading a field rather than relying on element identity. `Filter` now takes an explicit `prepareThreshold`; existing callers pass the new `rbac.DefaultFilterThreshold` (10), and `checkAuthorization` passes 50, above the ~35-group crossover measured for this workload, so subjects with few objects of a given type keep the per-object path and cannot regress. ## Stacking This is stacked on top of #27244. `Filter` runs `Prepare` (partial evaluation), and those residuals are only compact once #27244's set-membership residuals land. On plain `main` the existing O(N) residual fanout means batching can regress at high org counts, so this change should land with or after #27244. <details> <summary>Decision log</summary> ### Bottleneck - `site/src/modules/permissions/organizations.ts` defines ~14 permission checks per org; `organizationsPermissions()` flattens them across all orgs into one `POST /api/v2/authcheck`. A 100-org request is ~1400 checks. - `checkAuthorization` looped serially, calling `Authorizer.Authorize` (full eval) once per check. - The endpoint's `maxFetch = 10` only caps checks that carry a `resource_id`, not total checks, so it does not bound this workload. ### Approach - Group checks by `(action, resource type)` and run each group through `rbac.Filter`, which does one partial evaluation (`Prepare`) and reuses it across the group. - Carry the response key as data in a small value struct implementing `RBACObject()`, so allowed results map back to keys without pointer identity: ```go type authorizeCheck struct { key string object rbac.Object } func (c authorizeCheck) RBACObject() rbac.Object { return c.object } ``` - `Filter` takes a required `prepareThreshold int` (no functional options). Generic callers pass `rbac.DefaultFilterThreshold = 10`; `/authcheck` passes 50 because the measured crossover for this workload is ~35 groups. ### Alternatives rejected - **Bounded `errgroup` parallelism**: reduced wall time at high org counts but not aggregate work (allocations flat). Discarded in favor of reducing work via partial evaluation. - **Symmetric-deny Rego simplification** (on the #27244 branch): replacing the known-org deny-fold with symmetric `org := -1` / `scope_org := -1` rules failed existing SQL-compile tests. A `-1` known-org vote gated by `not org = -1` produces a negated membership test over the unknown org id, which OPA emits as an unconvertible support rule. #27244's fold (`member_allow - org_deny`, a positive set-difference membership test) is therefore load-bearing, not incidental. </details> --- Authored with Coder Agents. --------- Co-authored-by: Steven Masley <Emyrk@users.noreply.github.com>
340 lines
12 KiB
Go
340 lines
12 KiB
Go
package coderd
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"net/http"
|
|
|
|
"github.com/google/uuid"
|
|
"golang.org/x/xerrors"
|
|
|
|
"cdr.dev/slog/v3"
|
|
"github.com/coder/coder/v2/coderd/database/dbauthz"
|
|
"github.com/coder/coder/v2/coderd/httpapi"
|
|
"github.com/coder/coder/v2/coderd/httpmw"
|
|
"github.com/coder/coder/v2/coderd/rbac"
|
|
"github.com/coder/coder/v2/coderd/rbac/policy"
|
|
"github.com/coder/coder/v2/codersdk"
|
|
)
|
|
|
|
// AuthorizeFilter takes a list of objects and returns the filtered list of
|
|
// objects that the user is authorized to perform the given action on.
|
|
// This is faster than calling Authorize() on each object.
|
|
func AuthorizeFilter[O rbac.Objecter](h *HTTPAuthorizer, r *http.Request, action policy.Action, objects []O) ([]O, error) {
|
|
roles := httpmw.UserAuthorization(r.Context())
|
|
objects, err := rbac.Filter(r.Context(), h.Authorizer, roles, action, objects, rbac.DefaultFilterThreshold)
|
|
if err != nil {
|
|
// Log the error as Filter should not be erroring.
|
|
h.Logger.Error(r.Context(), "authorization filter failed",
|
|
slog.Error(err),
|
|
slog.F("user_id", roles.ID),
|
|
slog.F("username", roles),
|
|
slog.F("roles", roles.SafeRoleNames()),
|
|
slog.F("scope", roles.SafeScopeName()),
|
|
slog.F("route", r.URL.Path),
|
|
slog.F("action", action),
|
|
)
|
|
return nil, err
|
|
}
|
|
return objects, nil
|
|
}
|
|
|
|
type HTTPAuthorizer struct {
|
|
Authorizer rbac.Authorizer
|
|
Logger slog.Logger
|
|
}
|
|
|
|
// Authorize will return false if the user is not authorized to do the action.
|
|
// This function will log appropriately, but the caller must return an
|
|
// error to the api client.
|
|
// Eg:
|
|
//
|
|
// if !api.Authorize(...) {
|
|
// httpapi.Forbidden(rw)
|
|
// return
|
|
// }
|
|
func (api *API) Authorize(r *http.Request, action policy.Action, object rbac.Objecter) bool {
|
|
return api.HTTPAuth.Authorize(r, action, object)
|
|
}
|
|
|
|
// Authorize will return false if the user is not authorized to do the action.
|
|
// This function will log appropriately, but the caller must return an
|
|
// error to the api client.
|
|
// Eg:
|
|
//
|
|
// if !h.Authorize(...) {
|
|
// httpapi.Forbidden(rw)
|
|
// return
|
|
// }
|
|
func (h *HTTPAuthorizer) Authorize(r *http.Request, action policy.Action, object rbac.Objecter) bool {
|
|
roles := httpmw.UserAuthorization(r.Context())
|
|
err := h.Authorizer.Authorize(r.Context(), roles, action, object.RBACObject())
|
|
if err != nil {
|
|
// Log the errors for debugging
|
|
internalError := new(rbac.UnauthorizedError)
|
|
logger := h.Logger
|
|
if xerrors.As(err, internalError) {
|
|
logger = h.Logger.With(slog.F("internal_error", internalError.Internal()))
|
|
}
|
|
// Log information for debugging. This will be very helpful
|
|
// in the early days
|
|
logger.Warn(r.Context(), "requester is not authorized to access the object",
|
|
slog.F("roles", roles.SafeRoleNames()),
|
|
slog.F("actor_id", roles.ID),
|
|
slog.F("actor_name", roles),
|
|
slog.F("scope", roles.SafeScopeName()),
|
|
slog.F("route", r.URL.Path),
|
|
slog.F("action", action),
|
|
slog.F("object", object),
|
|
)
|
|
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
// AuthorizeContext checks whether the RBAC subject on the context
|
|
// is authorized to perform the given action. The subject must have
|
|
// been set via dbauthz.As or the ExtractAPIKey middleware. Returns
|
|
// false if the subject is missing or unauthorized.
|
|
func (h *HTTPAuthorizer) AuthorizeContext(ctx context.Context, action policy.Action, object rbac.Objecter) bool {
|
|
roles, ok := dbauthz.ActorFromContext(ctx)
|
|
if !ok {
|
|
h.Logger.Error(ctx, "no authorization actor in context")
|
|
return false
|
|
}
|
|
err := h.Authorizer.Authorize(ctx, roles, action, object.RBACObject())
|
|
if err != nil {
|
|
internalError := new(rbac.UnauthorizedError)
|
|
logger := h.Logger
|
|
if xerrors.As(err, internalError) {
|
|
logger = h.Logger.With(slog.F("internal_error", internalError.Internal()))
|
|
}
|
|
logger.Warn(ctx, "requester is not authorized to access the object",
|
|
slog.F("roles", roles.SafeRoleNames()),
|
|
slog.F("actor_id", roles.ID),
|
|
slog.F("actor_name", roles),
|
|
slog.F("scope", roles.SafeScopeName()),
|
|
slog.F("action", action),
|
|
slog.F("object", object),
|
|
)
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
// AuthorizeSQLFilter returns an authorization filter that can used in a
|
|
// SQL 'WHERE' clause. If the filter is used, the resulting rows returned
|
|
// from postgres are already authorized, and the caller does not need to
|
|
// call 'Authorize()' on the returned objects.
|
|
// Note the authorization is only for the given action and object type.
|
|
func (h *HTTPAuthorizer) AuthorizeSQLFilter(r *http.Request, action policy.Action, objectType string) (rbac.PreparedAuthorized, error) {
|
|
roles := httpmw.UserAuthorization(r.Context())
|
|
prepared, err := h.Authorizer.Prepare(r.Context(), roles, action, objectType)
|
|
if err != nil {
|
|
return nil, xerrors.Errorf("prepare filter: %w", err)
|
|
}
|
|
|
|
return prepared, nil
|
|
}
|
|
|
|
// AuthorizeSQLFilterContext is like AuthorizeSQLFilter but reads the
|
|
// RBAC subject from the context directly rather than from an
|
|
// *http.Request. The subject must have been set via dbauthz.As.
|
|
func (h *HTTPAuthorizer) AuthorizeSQLFilterContext(ctx context.Context, action policy.Action, objectType string) (rbac.PreparedAuthorized, error) {
|
|
roles, ok := dbauthz.ActorFromContext(ctx)
|
|
if !ok {
|
|
return nil, xerrors.New("no authorization actor in context")
|
|
}
|
|
prepared, err := h.Authorizer.Prepare(ctx, roles, action, objectType)
|
|
if err != nil {
|
|
return nil, xerrors.Errorf("prepare filter: %w", err)
|
|
}
|
|
|
|
return prepared, nil
|
|
}
|
|
|
|
// authcheckFilterThreshold is the per-(action, resource type) group size at or
|
|
// above which checkAuthorization lets rbac.Filter switch to a single partial
|
|
// evaluation. For a subject in many organizations, a group can hold one object
|
|
// per organization while the subject also carries one role per organization, so
|
|
// Prepare cost grows with the group size. The measured crossover where batching
|
|
// beats per-object evaluation is ~35 (DEVEX-608), so this sits above it:
|
|
// subjects with few objects of a given type keep the per-object path and cannot
|
|
// regress, and only large groups pay for and benefit from partial evaluation.
|
|
// It is higher than rbac.DefaultFilterThreshold because that default assumes a
|
|
// Prepare cost independent of the input size.
|
|
const authcheckFilterThreshold = 50
|
|
|
|
// authorizeCheck carries an authorization check's response key alongside its
|
|
// resolved RBAC object. It implements rbac.Objecter so a batch of same-typed
|
|
// checks can be run through rbac.Filter; because the key travels with the
|
|
// object, the filtered subset maps back to keys by reading the field, without
|
|
// relying on element identity.
|
|
type authorizeCheck struct {
|
|
key string
|
|
object rbac.Object
|
|
}
|
|
|
|
func (c authorizeCheck) RBACObject() rbac.Object { return c.object }
|
|
|
|
// checkAuthorization returns if the current API key can use the given
|
|
// permissions, factoring in the current user's roles and the API key scopes.
|
|
//
|
|
// @Summary Check authorization
|
|
// @ID check-authorization
|
|
// @Security CoderSessionToken
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Tags Authorization
|
|
// @Param request body codersdk.AuthorizationRequest true "Authorization request"
|
|
// @Success 200 {object} codersdk.AuthorizationResponse
|
|
// @Router /api/v2/authcheck [post]
|
|
func (api *API) checkAuthorization(rw http.ResponseWriter, r *http.Request) {
|
|
ctx := r.Context()
|
|
auth := httpmw.UserAuthorization(r.Context())
|
|
|
|
var params codersdk.AuthorizationRequest
|
|
if !httpapi.Read(ctx, rw, r, ¶ms) {
|
|
return
|
|
}
|
|
|
|
api.Logger.Debug(ctx, "check-auth",
|
|
slog.F("my_id", httpmw.APIKey(r).UserID),
|
|
slog.F("got_id", auth.ID),
|
|
slog.F("name", auth),
|
|
slog.F("roles", auth.SafeRoleNames()),
|
|
slog.F("scope", auth.SafeScopeName()),
|
|
)
|
|
|
|
response := make(codersdk.AuthorizationResponse)
|
|
// Prevent using too many resources by ID. This prevents database abuse
|
|
// from this endpoint. This also prevents misuse of this endpoint, as
|
|
// resource_id should be used for single objects, not for a list of them.
|
|
var (
|
|
idFetch int
|
|
maxFetch = 10
|
|
)
|
|
for _, v := range params.Checks {
|
|
if v.Object.ResourceID != "" {
|
|
idFetch++
|
|
}
|
|
}
|
|
if idFetch > maxFetch {
|
|
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
|
Message: fmt.Sprintf(
|
|
"Endpoint only supports using \"resource_id\" field %d times, found %d usages. Remove %d objects with this field set.",
|
|
maxFetch, idFetch, idFetch-maxFetch,
|
|
),
|
|
})
|
|
return
|
|
}
|
|
|
|
// Group the checks by (action, resource type) and authorize each group with
|
|
// rbac.Filter, which amortizes a single partial evaluation across the group
|
|
// once it is large enough. Each check carries its response key so the
|
|
// filtered subset maps back without relying on element identity.
|
|
type checkGroup struct {
|
|
action policy.Action
|
|
objectType string
|
|
}
|
|
groups := make(map[checkGroup][]authorizeCheck)
|
|
for k, v := range params.Checks {
|
|
if v.Object.ResourceType == "" {
|
|
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
|
Message: fmt.Sprintf("Object's \"resource_type\" field must be defined for key %q.", k),
|
|
})
|
|
return
|
|
}
|
|
|
|
obj := rbac.Object{
|
|
Owner: v.Object.OwnerID,
|
|
OrgID: v.Object.OrganizationID,
|
|
Type: string(v.Object.ResourceType),
|
|
AnyOrgOwner: v.Object.AnyOrgOwner,
|
|
}
|
|
if obj.Owner == codersdk.Me {
|
|
obj.Owner = auth.ID
|
|
}
|
|
|
|
// If a resource ID is specified, fetch that specific resource.
|
|
if v.Object.ResourceID != "" {
|
|
id, err := uuid.Parse(v.Object.ResourceID)
|
|
if err != nil {
|
|
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
|
Message: fmt.Sprintf("Object %q id is not a valid uuid.", v.Object.ResourceID),
|
|
Validations: []codersdk.ValidationError{{Field: "resource_id", Detail: err.Error()}},
|
|
})
|
|
return
|
|
}
|
|
|
|
var dbObj rbac.Objecter
|
|
var dbErr error
|
|
// Only support referencing some resources by ID.
|
|
switch string(v.Object.ResourceType) {
|
|
case rbac.ResourceWorkspace.Type:
|
|
dbObj, dbErr = api.Database.GetWorkspaceByID(ctx, id)
|
|
case rbac.ResourceTemplate.Type:
|
|
dbObj, dbErr = api.Database.GetTemplateByID(ctx, id)
|
|
case rbac.ResourceUser.Type:
|
|
dbObj, dbErr = api.Database.GetUserByID(ctx, id)
|
|
case rbac.ResourceGroup.Type:
|
|
dbObj, dbErr = api.Database.GetGroupByID(ctx, id)
|
|
default:
|
|
msg := fmt.Sprintf("Object type %q does not support \"resource_id\" field.", v.Object.ResourceType)
|
|
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
|
Message: msg,
|
|
Validations: []codersdk.ValidationError{{Field: "resource_type", Detail: msg}},
|
|
})
|
|
return
|
|
}
|
|
if dbErr != nil {
|
|
// 404 or unauthorized is false
|
|
response[k] = false
|
|
continue
|
|
}
|
|
obj = dbObj.RBACObject()
|
|
}
|
|
|
|
// AnyOrgOwner objects have no verified semantics under partial
|
|
// evaluation: Filter's prepared path can deny objects that a full
|
|
// evaluation allows (authz_internal_test.go skips the full-vs-partial
|
|
// equivalence assertion for them). Authorize them per-object with a full
|
|
// evaluation instead of grouping them into the batched Filter path.
|
|
if obj.AnyOrgOwner {
|
|
err := api.Authorizer.Authorize(ctx, auth, policy.Action(v.Action), obj)
|
|
response[k] = err == nil
|
|
continue
|
|
}
|
|
|
|
group := checkGroup{action: policy.Action(v.Action), objectType: obj.Type}
|
|
groups[group] = append(groups[group], authorizeCheck{key: k, object: obj})
|
|
}
|
|
|
|
for group, checks := range groups {
|
|
allowed, err := rbac.Filter(ctx, api.Authorizer, auth, group.action, checks, authcheckFilterThreshold)
|
|
if err != nil {
|
|
// A Filter error is never a per-object denial: per-object rejections
|
|
// are filtered out inside Filter, so only Prepare failures and context
|
|
// errors reach here. Reporting the group as denied would hide an
|
|
// evaluation failure behind a "not permitted" answer, so surface it.
|
|
if ctx.Err() != nil {
|
|
// The client went away or the request was canceled; there is no
|
|
// useful response to write and nothing worth logging.
|
|
return
|
|
}
|
|
httpapi.InternalServerError(rw, xerrors.Errorf("authorize %q %q: %w", group.action, group.objectType, err))
|
|
return
|
|
}
|
|
// Default to denied, then mark the checks Filter allowed.
|
|
for _, c := range checks {
|
|
response[c.key] = false
|
|
}
|
|
for _, c := range allowed {
|
|
response[c.key] = true
|
|
}
|
|
}
|
|
|
|
httpapi.Write(ctx, rw, http.StatusOK, response)
|
|
}
|