Skip to content

Commit

Permalink
refactor: authz (#708)
Browse files Browse the repository at this point in the history
  • Loading branch information
leg100 authored Nov 14, 2024
1 parent 41f5669 commit 26ac329
Show file tree
Hide file tree
Showing 78 changed files with 1,123 additions and 1,135 deletions.
5 changes: 2 additions & 3 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ require (
github.com/antchfx/htmlquery v1.3.0
github.com/bradleyfalzon/ghinstallation/v2 v2.11.0
github.com/buildkite/terminal-to-html v3.2.0+incompatible
github.com/cenkalti/backoff v2.2.1+incompatible
github.com/cenkalti/backoff/v4 v4.3.0
github.com/coreos/go-oidc/v3 v3.5.0
github.com/fatih/color v1.16.0
Expand All @@ -38,7 +39,7 @@ require (
github.com/lestrrat-go/jwx/v2 v2.1.1
github.com/mitchellh/iochan v1.0.0
github.com/pkg/errors v0.9.1
github.com/playwright-community/playwright-go v0.4702.0
github.com/playwright-community/playwright-go v0.4802.0
github.com/prometheus/client_golang v1.14.0
github.com/sdassow/atomic v0.0.1
github.com/spf13/cobra v1.8.0
Expand Down Expand Up @@ -66,7 +67,6 @@ require (
github.com/antchfx/xpath v1.2.3 // indirect
github.com/apparentlymart/go-textseg/v13 v13.0.0 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/cenkalti/backoff v2.2.1+incompatible // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/deckarep/golang-set/v2 v2.6.0 // indirect
Expand Down Expand Up @@ -123,7 +123,6 @@ require (
go.opentelemetry.io/otel v1.30.0 // indirect
go.opentelemetry.io/otel/metric v1.30.0 // indirect
go.opentelemetry.io/otel/trace v1.30.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
golang.org/x/crypto v0.27.0 // indirect
golang.org/x/sys v0.25.0 // indirect
golang.org/x/text v0.18.0 // indirect
Expand Down
8 changes: 2 additions & 6 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -88,8 +88,6 @@ github.com/buildkite/terminal-to-html v3.2.0+incompatible h1:WdXzl7ZmYzCAz4pElZo
github.com/buildkite/terminal-to-html v3.2.0+incompatible/go.mod h1:BFFdFecOxCgjdcarqI+8izs6v85CU/1RA/4Bqh4GR7E=
github.com/cenkalti/backoff v2.2.1+incompatible h1:tNowT99t7UNflLxfYYSlKYsBpXdEet03Pg2g16Swow4=
github.com/cenkalti/backoff v2.2.1+incompatible/go.mod h1:90ReRw6GdpyfrHakVjL/QHaoyV4aDUVVkXQJJJ3NXXM=
github.com/cenkalti/backoff/v4 v4.2.1 h1:y4OZtCnogmCPw98Zjyt5a6+QwPLGkiQsYW5oUqylYbM=
github.com/cenkalti/backoff/v4 v4.2.1/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
Expand Down Expand Up @@ -364,8 +362,8 @@ github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINE
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/playwright-community/playwright-go v0.4702.0 h1:3CwNpk4RoA42tyhmlgPDMxYEYtMydaeEqMYiW0RNlSY=
github.com/playwright-community/playwright-go v0.4702.0/go.mod h1:bpArn5TqNzmP0jroCgw4poSOG9gSeQg490iLqWAaa7w=
github.com/playwright-community/playwright-go v0.4802.0 h1:FSuvi5Pg/xp+n7vFpu2wGldwSQ3grsaDlHFRfHRQiy4=
github.com/playwright-community/playwright-go v0.4802.0/go.mod h1:kBNWs/w2aJ2ZUp1wEOOFLXgOqvppFngM5OS+qyhl+ZM=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw=
Expand Down Expand Up @@ -472,8 +470,6 @@ go.opentelemetry.io/otel/sdk v1.29.0 h1:vkqKjk7gwhS8VaWb0POZKmIEDimRCMsopNYnriHy
go.opentelemetry.io/otel/sdk v1.29.0/go.mod h1:pM8Dx5WKnvxLCb+8lG1PRNIDxu9g9b9g59Qr7hfAAok=
go.opentelemetry.io/otel/trace v1.30.0 h1:7UBkkYzeg3C7kQX8VAidWh2biiQbtAKjyIML8dQ9wmc=
go.opentelemetry.io/otel/trace v1.30.0/go.mod h1:5EyKqTzzmyqB9bwtCCq6pDLktPK6fmGf/Dph+8VI02o=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20190426145343-a29dc8fdc734/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
Expand Down
199 changes: 195 additions & 4 deletions internal/authz/authorizer.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,204 @@ package authz

import (
"context"
"errors"
"fmt"
"log/slog"

"github.com/go-logr/logr"
"github.com/leg100/otf/internal"
"github.com/leg100/otf/internal/rbac"
"github.com/leg100/otf/internal/resource"
)

// Authorizer is capable of granting or denying access to resources based on the
// subject contained within the context.
type Authorizer interface {
CanAccess(ctx context.Context, action rbac.Action, id resource.ID) (Subject, error)
// Authorizer intermediates authorization between subjects (entities requesting
// access) and resources (the entities to which access is being requested).
type Authorizer struct {
logr.Logger
WorkspacePolicyGetter

organizationResolvers map[resource.Kind]OrganizationResolver
workspaceResolvers map[resource.Kind]WorkspaceResolver
}

// Interface provides an interface for services to use to permit swapping out
// the authorizer for tests.
type Interface interface {
Authorize(ctx context.Context, action rbac.Action, req *AccessRequest, opts ...CanAccessOption) (Subject, error)
CanAccess(ctx context.Context, action rbac.Action, req *AccessRequest) bool
}

func NewAuthorizer(logger logr.Logger) *Authorizer {
return &Authorizer{
Logger: logger,
organizationResolvers: make(map[resource.Kind]OrganizationResolver),
workspaceResolvers: make(map[resource.Kind]WorkspaceResolver),
}
}

type WorkspacePolicyGetter interface {
GetWorkspacePolicy(ctx context.Context, workspaceID resource.ID) (WorkspacePolicy, error)
}

// OrganizationResolver takes the ID of a resource and returns the name of the
// organization it belongs to.
type OrganizationResolver func(ctx context.Context, id resource.ID) (string, error)

// WorkspaceResolver takes the ID of a resource and returns the ID of the
// workspace it belongs to.
type WorkspaceResolver func(ctx context.Context, id resource.ID) (resource.ID, error)

// RegisterOrganizationResolver registers with the authorizer the ability to
// resolve access requests for a specific resource kind to the name of the
// organization the resource belongs to.
//
// This is necessary because authorization is determined not only on resource ID
// but on the name of the organization the resource belongs to.
func (a *Authorizer) RegisterOrganizationResolver(kind resource.Kind, resolver OrganizationResolver) {
a.organizationResolvers[kind] = resolver
}

// RegisterWorkspaceResolver registers with the authorizer the ability to
// resolve access requests for a specific resource kind to the workspace ID the
// resource belongs to.
//
// This is necessary because authorization is often determined based on
// workspace ID, and not the ID of a run, state version, etc.
func (a *Authorizer) RegisterWorkspaceResolver(kind resource.Kind, resolver WorkspaceResolver) {
a.workspaceResolvers[kind] = resolver
}

// Options for configuring the individual calls of CanAccess.

type CanAccessOption func(*canAccessConfig)

// WithoutErrorLogging disables logging an unauthorized error. This can be
// useful if just checking if a user can do something.
func WithoutErrorLogging() CanAccessOption {
return func(cfg *canAccessConfig) {
cfg.disableLogs = true
}
}

type canAccessConfig struct {
disableLogs bool
}

// Authorize determines whether the subject can carry out an action on a
// resource. The subject is expected to be contained within the context. If the
// access request is nil then it's assumed the request is for access to the
// entire site (the highest level).
func (a *Authorizer) Authorize(ctx context.Context, action rbac.Action, req *AccessRequest, opts ...CanAccessOption) (Subject, error) {
var cfg canAccessConfig
for _, fn := range opts {
fn(&cfg)
}
subj, err := SubjectFromContext(ctx)
if err != nil {
return nil, err
}
// Allow context to contain specific instruction to skip authorization.
// Should only be used for testing purposes.
if SkipAuthz(ctx) {
return subj, nil
}
// Wrapped in function in order to log error messages uniformly.
err = func() error {
if req != nil && req.ID != nil {
// Check if resource kind is registered for its ID to be resolved to workspace
// ID.
if resolver, ok := a.workspaceResolvers[req.ID.Kind()]; ok {
workspaceID, err := resolver(ctx, *req.ID)
if err != nil {
return fmt.Errorf("resolving workspace ID: %w", err)
}
// Authorize workspace ID instead
req.ID = &workspaceID
}
// If the resource kind is a workspace, then fetch its policy.
if req.ID.Kind() == resource.WorkspaceKind {
policy, err := a.GetWorkspacePolicy(ctx, *req.ID)
if err != nil {
return fmt.Errorf("fetching workspace policy: %w", err)
}
req.WorkspacePolicy = &policy
}
// Resolve the organization if not already provided. Every resource
// belongs to an organization, so there should be a resolver for each
// resource kind to resolve the resource ID to the organization it
// belongs to.
if req.Organization == "" {
resolver, ok := a.organizationResolvers[req.ID.Kind()]
if !ok {
return errors.New("resource kind is missing organization resolver")
}
organization, err := resolver(ctx, *req.ID)
if err != nil {
return fmt.Errorf("resolving organization: %w", err)
}
req.Organization = organization
}
}
// Subject determines whether it is allowed to access resource.
if !subj.CanAccess(action, req) {
return internal.ErrAccessNotPermitted
}
return nil
}()
if err != nil && !cfg.disableLogs {
a.Error(err, "authorization failure",
"resource", req,
"action", action.String(),
"subject", subj,
)
}
return subj, err
}

// CanAccess is a helper to boil down an access request to a true/false
// decision, with any error encountered interpreted as false.
func (a *Authorizer) CanAccess(ctx context.Context, action rbac.Action, req *AccessRequest) bool {
_, err := a.Authorize(ctx, action, req, WithoutErrorLogging())
return err == nil
}

// AccessRequest is a request for access to either an organization or an
// individual resource.
type AccessRequest struct {
// Organization name to which access is being requested.
Organization string
// ID of resource to which access is being requested. If nil then the action
// is being requested on the organization.
ID *resource.ID
// WorkspacePolicy specifies workspace-specific permissions for the resource
// specified by ID. Only non-nil if ID refers to a workspace.
WorkspacePolicy *WorkspacePolicy
}

// WorkspacePolicy binds workspace permissions to a workspace
type WorkspacePolicy struct {
Permissions []WorkspacePermission
// Whether workspace permits its state to be consumed by all workspaces in
// the organization.
GlobalRemoteState bool
}

// WorkspacePermission binds a role to a team.
type WorkspacePermission struct {
TeamID resource.ID
Role rbac.Role
}

func (r *AccessRequest) LogValue() slog.Value {
if r == nil {
return slog.StringValue("site")
} else {
attrs := []slog.Attr{
slog.String("organization", r.Organization),
}
if r.ID != nil {
attrs = append(attrs, slog.String("resource_id", r.ID.String()))
}
return slog.GroupValue(attrs...)
}
}
22 changes: 22 additions & 0 deletions internal/authz/authorizer_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package authz

import (
"context"
"testing"

"github.com/leg100/otf/internal/logr"
"github.com/leg100/otf/internal/rbac"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestAuthorizer(t *testing.T) {
authorizer := NewAuthorizer(logr.Discard())
user := &Superuser{}
ctx := AddSubjectToContext(context.Background(), user)

got, err := authorizer.Authorize(ctx, rbac.ListUsersAction, nil)
require.NoError(t, err)

assert.Equal(t, user, got)
}
7 changes: 5 additions & 2 deletions internal/authz/authorizer_test_helper.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import (
"context"

"github.com/leg100/otf/internal/rbac"
"github.com/leg100/otf/internal/resource"
)

type allowAllAuthorizer struct {
Expand All @@ -17,6 +16,10 @@ func NewAllowAllAuthorizer() *allowAllAuthorizer {
}
}

func (a *allowAllAuthorizer) CanAccess(context.Context, rbac.Action, resource.ID) (Subject, error) {
func (a *allowAllAuthorizer) Authorize(context.Context, rbac.Action, *AccessRequest, ...CanAccessOption) (Subject, error) {
return a.User, nil
}

func (a *allowAllAuthorizer) CanAccess(context.Context, rbac.Action, *AccessRequest) bool {
return true
}
Loading

0 comments on commit 26ac329

Please sign in to comment.