Skip to content
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 our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

refactor: authz #708

Merged
merged 15 commits into from
Nov 14, 2024
Merged
Show file tree
Hide file tree
Changes from all 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
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
Loading