Files
Nico edf4a45df9 Stop the delete host endpoint from revealing out-of-fleet host existence (#49645)
<!-- Add the related story/sub-task/bug number, like Resolves #123, or
remove if NA -->
**Related issue:** Resolves: N/A

# Checklist for submitter

- [x] Changes file added for user-visible changes in `changes/`,
`orbit/changes/` or `ee/fleetd-chrome/changes`.
See [Changes
files](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/guides/committing-changes.md#changes-files)
for more information.

## Testing

- [x] Added/updated automated tests

- [x] QA'd all new/changed functionality manually


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **Bug Fixes**
* Updated host deletion behavior to return consistent “not found”
responses when the host doesn’t exist or isn’t visible to the requester.
* Prevented out-of-scope delete attempts from disclosing whether the
target host exists (now returns “not found” instead of “forbidden”).
* Preserved “forbidden” errors when the host is visible but the
requester lacks delete permission.
* **Tests**
* Added/updated authorization and deletion coverage to verify the new
response-masking behavior.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-07-22 11:12:16 -03:00

242 lines
8.2 KiB
Go

// Package authz implements the authorization checking logic via Go and OPA's
// Rego.
//
// Policy is defined in policy.rego. Policy is evaluated by Authorizer, defined
// in authz.go.
//
// See https://www.openpolicyagent.org/ for more details on OPA and Rego.
package authz
import (
"bytes"
"context"
_ "embed"
"encoding/json"
"fmt"
authz_ctx "github.com/fleetdm/fleet/v4/server/contexts/authz"
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
"github.com/fleetdm/fleet/v4/server/contexts/viewer"
"github.com/fleetdm/fleet/v4/server/fleet"
platform_authz "github.com/fleetdm/fleet/v4/server/platform/authz"
"github.com/open-policy-agent/opa/rego"
)
// Authorizer stores the compiled policy and performs authorization checks.
type Authorizer struct {
query rego.PreparedEvalQuery
}
// Load the policy from policy.rego in this directory.
//
//go:embed policy.rego
var policy string
// NewAuthorizer creates a new authorizer by compiling the policy embedded in
// policy.rego.
func NewAuthorizer() (*Authorizer, error) {
ctx := context.Background()
query, err := rego.New(
rego.Query("allowed = data.authz.allow"),
rego.Module("policy.rego", policy),
).PrepareForEval(ctx)
if err != nil {
return nil, fmt.Errorf("prepare query: %w", err)
}
return &Authorizer{query: query}, nil
}
// Must returns a new authorizer, or panics if there is an error.
func Must() *Authorizer {
auth, err := NewAuthorizer()
if err != nil {
panic(err)
}
return auth
}
// SkipAuthorization must be used by service methods that do not need an
// authorization check.
//
// Please be sure it is appropriate to skip authorization when using this. You
// MUST leave a comment above the use of this function explaining why
// authorization is skipped, starting with `skipauth:`
//
// This will mark the authorization context (if any) as checked without
// performing any authorization check.
func (a *Authorizer) SkipAuthorization(ctx context.Context) {
// Mark the authorization context as checked (otherwise middleware will
// error).
if authctx, ok := authz_ctx.FromContext(ctx); ok {
authctx.SetChecked()
}
}
// IsAuthenticatedWith returns true if the request has been authenticated with
// the specified authentication method, false otherwise. This is useful to avoid
// calling Authorize if the request is authenticated with a method that doesn't
// support granular authorizations - provided it is ok to grant access to the
// protected data.
func (a *Authorizer) IsAuthenticatedWith(ctx context.Context, method authz_ctx.AuthenticationMethod) bool {
if authctx, ok := authz_ctx.FromContext(ctx); ok {
return authctx.AuthnMethod() == method
}
return false
}
// Authorize checks authorization for the provided object, and action,
// retrieving the subject from the context.
//
// Object type may be dynamic. This method also marks the request authorization
// context as checked, so that we don't return an error at the end of the
// request.
func (a *Authorizer) Authorize(ctx context.Context, object, action interface{}) error {
// Mark the authorization context as checked (otherwise middleware will
// error).
if authctx, ok := authz_ctx.FromContext(ctx); ok {
authctx.SetChecked()
}
subject := UserFromContext(ctx)
if subject == nil {
return ForbiddenWithInternal("nil subject always forbidden", subject, object, action)
}
// Map subject and object to map[string]interface{} for use in policy evaluation.
subjectInterface, err := jsonToInterface(subject)
if err != nil {
return ForbiddenWithInternal("subject to interface: "+err.Error(), subject, object, action)
}
objectInterface, err := jsonToInterface(object)
if err != nil {
return ForbiddenWithInternal("object to interface: "+err.Error(), subject, object, action)
}
// Perform the check via Rego.
input := map[string]interface{}{
"subject": subjectInterface,
"object": objectInterface,
"action": action,
}
results, err := a.query.Eval(ctx, rego.EvalInput(input))
if err != nil {
return ForbiddenWithInternal("policy evaluation failed: "+err.Error(), subject, object, action)
}
if len(results) != 1 {
return ForbiddenWithInternal(fmt.Sprintf("expected 1 policy result, got %d", len(results)), subject, object, action)
}
if results[0].Bindings["allowed"] != true {
return ForbiddenWithInternal("policy disallows request", subject, object, action)
}
return nil
}
// AuthorizeOrNotFound authorizes writeAction on writeObject. If that fails,
// it also checks fleet.ActionRead on readObject; if the caller can't even
// read it, notFoundErr is returned instead of the write failure, so a
// resource entirely outside the caller's visibility is indistinguishable
// from one that doesn't exist. If the caller CAN read it, or notFoundErr is
// nil, the original write-authorization failure is returned unchanged: in
// the first case no new information is disclosed by it (the caller already
// knows the resource exists); in the second, masking would incorrectly
// return nil (success) for a caller who was never authorized.
func (a *Authorizer) AuthorizeOrNotFound(ctx context.Context, writeObject, writeAction, readObject any, notFoundErr error) error {
writeErr := a.Authorize(ctx, writeObject, writeAction)
if writeErr == nil {
return nil
}
if readErr := a.Authorize(ctx, readObject, fleet.ActionRead); readErr == nil || notFoundErr == nil {
return writeErr
}
// The caller can't read this resource either: report notFoundErr instead
// of writeErr, so its existence isn't disclosed. Still record the real
// cause for observability, so a systemic authz/policy failure isn't
// silently reported as "not found" for every caller.
ctxerr.Handle(ctx, writeErr)
return notFoundErr
}
// ExtraAuthzer is the interface to implement extra fields for the policy.
type ExtraAuthzer interface {
// ExtraAuthz returns the extra key/value pairs for the type.
ExtraAuthz() (map[string]interface{}, error)
}
// jsonToInterface turns any type that can be JSON (un)marshaled into an
// map[string]interface{} for evaluation by the OPA engine. Nil is returned as nil.
func jsonToInterface(in interface{}) (interface{}, error) {
// Special cases for nil and string.
if in == nil {
return nil, nil
}
if _, ok := in.(string); ok {
return in, nil
}
// Anything that makes it to here should be encodeable as a
// map[string]interface{} (structs, maps, etc.)
buf := bytes.Buffer{}
if err := json.NewEncoder(&buf).Encode(in); err != nil {
return nil, fmt.Errorf("encode input: %w", err)
}
d := json.NewDecoder(&buf)
// Note input numbers must be represented with json.Number according to
// https://pkg.go.dev/github.com/open-policy-agent/opa/rego#example-Rego.Eval-Input
d.UseNumber()
var out map[string]interface{}
if err := d.Decode(&out); err != nil {
return nil, fmt.Errorf("decode input: %w", err)
}
// Add the `type` property if the AuthzTyper interface is implemented.
if typer, ok := in.(platform_authz.AuthzTyper); ok {
out["type"] = typer.AuthzType()
}
// Add any extra key/values defined by the type.
if extra, ok := in.(ExtraAuthzer); ok {
extraKVs, err := extra.ExtraAuthz()
if err != nil {
return nil, fmt.Errorf("extra authz: %w", err)
}
for k, v := range extraKVs {
if _, ok := out[k]; ok {
return nil, fmt.Errorf("existing authz value: %s", k)
}
out[k] = v
}
}
return out, nil
}
// UserFromContext retrieves a user from the viewer context, returning nil if
// there is no user.
func UserFromContext(ctx context.Context) *fleet.User {
vc, ok := viewer.FromContext(ctx)
if !ok {
return nil
}
return vc.User
}
// AuthorizerAdapter adapts the legacy Authorizer to the platform_authz.Authorizer interface.
// This provides stronger typing via AuthzTyper (instead of `any`) while reusing the existing OPA-based authorization.
type AuthorizerAdapter struct {
authorizer *Authorizer
}
// NewAuthorizerAdapter creates an adapter that wraps the legacy Authorizer.
func NewAuthorizerAdapter(authorizer *Authorizer) *AuthorizerAdapter {
return &AuthorizerAdapter{authorizer: authorizer}
}
// Authorize implements platform_authz.Authorizer.
func (a *AuthorizerAdapter) Authorize(ctx context.Context, subject platform_authz.AuthzTyper, action platform_authz.Action) error {
return a.authorizer.Authorize(ctx, subject, string(action))
}