Skip to content

Commit

Permalink
WIP: Add optional access control to API
Browse files Browse the repository at this point in the history
Optionally enable Attribute Based Access Control (ABAC) in the API. When
enabled, the API will look for a custom claim in the access token and
extract the attributes relevant to Enduro. If a custom scope needs to be
requested to get that claim, it has to be configured in the dashboard.

- Add OIDC ABAC configuration. Allows to enable or disable access
  control entirely and set a claim to get the attributes. That claim
  could be nested and include values unrelated to Enduro.
- Extend claims and token verifier to parse ABAC attributes from the
  access token based on configuration.
- Allow custom scopes in dashboard and configure it to work with the
  Keycloak instance for the dev. env. by default.
- Add scopes, forbidden error and security to a few endpoints in Goa's
  API design.
- Check scopes against attributes in API requests. As a first access
  control check, the scopes configured on the API design need to be
  included in the user attributes.
  - This check allows attributes with wildcards, so:
    - `package:read` will only provide access to that action(s).
    - `package:*` will provide access to all package related actions.
    - `*` will provide full access to all actions.
  - This attributes/scopes need to be defined and extended across the
    API and they don't have to be related to a single action/endpoint.
- Add claims with the extracted attributes to the context on each
  request. After that initial check is passed, the claims are included
  in the context to make them available for other possible access
  control checks on the endpoints implementation.
  • Loading branch information
jraddaoui committed Jun 19, 2024
1 parent 26edfe2 commit 29a32bb
Show file tree
Hide file tree
Showing 42 changed files with 2,046 additions and 84 deletions.
1 change: 1 addition & 0 deletions dashboard/.env
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
VITE_OIDC_AUTHORITY=\$VITE_OIDC_AUTHORITY
VITE_OIDC_CLIENT_ID=\$VITE_OIDC_CLIENT_ID
VITE_OIDC_REDIRECT_URI=\$VITE_OIDC_REDIRECT_URI
VITE_OIDC_EXTRA_SCOPES=\$VITE_OIDC_EXTRA_SCOPES
1 change: 1 addition & 0 deletions dashboard/.env.development
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
VITE_OIDC_AUTHORITY=http://keycloak:7470/realms/enduro
VITE_OIDC_CLIENT_ID=enduro
VITE_OIDC_REDIRECT_URI=http://localhost:8080/user/signin-callback
VITE_OIDC_EXTRA_SCOPES=enduro
7 changes: 6 additions & 1 deletion dashboard/src/auth.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
import { UserManager, WebStorageStateStore } from "oidc-client-ts";

let scope = "openid email profile";
if (import.meta.env.VITE_OIDC_EXTRA_SCOPES != undefined) {
scope += " " + import.meta.env.VITE_OIDC_EXTRA_SCOPES;
}

export default new UserManager({
authority: import.meta.env.VITE_OIDC_AUTHORITY,
client_id: import.meta.env.VITE_OIDC_CLIENT_ID,
redirect_uri: import.meta.env.VITE_OIDC_REDIRECT_URI,
scope: "openid email profile",
scope: scope,
userStore: new WebStorageStateStore({ store: window.localStorage }),
});
1 change: 1 addition & 0 deletions dashboard/src/env.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ interface ImportMetaEnv {
readonly VITE_OIDC_AUTHORITY: string;
readonly VITE_OIDC_CLIENT_ID: string;
readonly VITE_OIDC_REDIRECT_URI: string;
readonly VITE_OIDC_EXTRA_SCOPES: string;
}

interface ImportMeta {
Expand Down
28 changes: 28 additions & 0 deletions enduro.toml
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,40 @@ debug = false
corsOrigin = "http://localhost"

[api.auth]
# Enable API authentication, only OIDC auth. is available at the moment.
# The API only verifies the access token submitted on each request when
# enabled. Obtaining the token is done and configured in the dashboard.
enabled = true

[api.auth.oidc]
# OIDC provider URL (required with auth. is enabled).
providerURL = "http://keycloak:7470/realms/enduro"
# OIDC client ID that must be included in the `aud` claim from the access
# token (required with auth. is enabled).
clientID = "enduro"

[api.auth.oidc.abac]
# Enable Attribute Based Access Control (ABAC). If enabled, the API will
# check a configurable multivalue claim against required attributes based
# on each endpoint configuration. If extra scopes are needed to obtain
# such claim they can be configured in the dashboard.
enabled = false
# Claim name/path in the access token where the Enduro attributes should
# be found. If the claim is nested include all fields with a separator.
# For example "attributes.enduro", using "." as separator below. Required
# when ABAC is enabled.
claimPath = "enduro"
# Separator used to split the claim path fields. Defaults to "", it will
# try to find the claim path as it is in the top-level fields.
claimPathSeparator = ""
# Set a preffix to filter values from the configured claim. If the claim
# contains values unrelated to Enduro's ABAC, the ones that should be used
# in Enduro can be prefixed so they are the only ones considered. For
# example, a claim with the following values ["enduro:*", "unrelated"] will
# be filtered to ["*"] if "enduro:" is used as prefix. Defaults to "", all
# claim values will be considered.
claimValuePrefix = ""

[api.auth.ticket.redis]
address = "redis://redis.enduro-sdps:6379"
prefix = "enduro"
Expand Down
5 changes: 5 additions & 0 deletions hack/kube/base/enduro-dashboard.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,11 @@ spec:
secretKeyRef:
name: enduro-secret
key: oidc-redirect-url
- name: VITE_OIDC_EXTRA_SCOPES
valueFrom:
secretKeyRef:
name: enduro-secret
key: oidc-extra-scopes
ports:
- containerPort: 80
resources: {}
Expand Down
1 change: 1 addition & 0 deletions hack/kube/components/dev/enduro-secret.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,4 @@ stringData:
oidc-provider-url: http://keycloak:7470/realms/enduro
oidc-redirect-url: http://localhost:8080/user/signin-callback
oidc-client-id: enduro
oidc-extra-scopes: enduro
67 changes: 67 additions & 0 deletions internal/api/auth/claims.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
package auth

import (
"context"
"slices"
"strings"
)

type Claims struct {
Email string `json:"email,omitempty"`
EmailVerified bool `json:"email_verified,omitempty"`
Attributes []string `json:"-"`
}

// CheckAttributes verifies all required attributes are present in the claim
// attributes. It always verifies if the claim is nil (authentication disabled)
// or the attributes are nil (access control disabled). Attributes are verified
// by exact match or by having an ancestor with wildcard. For example, a claim
// with "*" or "package:*" as one of it's attributes will verify all package
// actions, like "package:list", "package:read", etc.
func (c *Claims) CheckAttributes(required []string) bool {
// Authentication disabled, access control disabled or all wildcard in claims.
if c == (*Claims)(nil) || c.Attributes == nil || slices.Contains(c.Attributes, "*") {
return true
}

// Check for all required attributes considering wildcards.
for _, attr := range required {
for {
if slices.Contains(c.Attributes, attr) {
break
}
attr, _ = strings.CutSuffix(attr, ":*")
lastColonIndex := strings.LastIndex(attr, ":")
if lastColonIndex == -1 {
return false
}

attr = attr[:lastColonIndex] + ":*"
}
}

return true
}

type contextUserClaimsType struct{}

var contextUserClaimsKey = &contextUserClaimsType{}

// WithUserClaims puts the user claims into the current context.
func WithUserClaims(ctx context.Context, claims *Claims) context.Context {
return context.WithValue(ctx, contextUserClaimsKey, claims)
}

// UserClaimsFromContext returns the user claims from the context.
// An empty string is returned if it was not found.
func UserClaimsFromContext(ctx context.Context) *Claims {
v := ctx.Value(contextUserClaimsKey)
if v == nil {
return nil
}
c, ok := v.(*Claims)
if !ok {
return nil

Check warning on line 64 in internal/api/auth/claims.go

View check run for this annotation

Codecov / codecov/patch

internal/api/auth/claims.go#L64

Added line #L64 was not covered by tests
}
return c
}
122 changes: 122 additions & 0 deletions internal/api/auth/claims_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
package auth_test

import (
"context"
"testing"

"gotest.tools/v3/assert"

"github.com/artefactual-sdps/enduro/internal/api/auth"
)

func TestUserClaimsFromContext(t *testing.T) {
t.Parallel()

t.Run("Returns claims when found", func(t *testing.T) {
t.Parallel()

claims := auth.Claims{
Email: "[email protected]",
EmailVerified: true,
Attributes: []string{"*"},
}

ctx := context.Background()
ctx = auth.WithUserClaims(ctx, &claims)
assert.Equal(t, auth.UserClaimsFromContext(ctx), &claims)
})

t.Run("Returns nil when not found", func(t *testing.T) {
t.Parallel()

ctx := context.Background()
assert.Equal(t, auth.UserClaimsFromContext(ctx), (*auth.Claims)(nil))
})
}

func TestCheckAttributes(t *testing.T) {
t.Parallel()

type test struct {
name string
claims *auth.Claims
attributes []string
want bool
}
for _, tt := range []test{
{
name: "Checks without required attributes",
claims: &auth.Claims{
Attributes: []string{},
},
attributes: []string{},
want: true,
},
{
name: "Checks a single attribute exists",
claims: &auth.Claims{
Attributes: []string{"package:list"},
},
attributes: []string{"package:list"},
want: true,
},
{
name: "Checks multiple attributes exist",
claims: &auth.Claims{
Attributes: []string{"package:list", "package:read"},
},
attributes: []string{"package:list", "package:read"},
want: true,
},
{
name: "Checks attribute is missing",
claims: &auth.Claims{
Attributes: []string{},
},
attributes: []string{"package:download"},
want: false,
},
{
name: "Checks attributes on nil claim (auth disabled)",
claims: (*auth.Claims)(nil),
attributes: []string{"package:list"},
want: true,
},
{
name: "Checks attributes on nil attributes (ABAC disabled)",
claims: &auth.Claims{},
attributes: []string{"package:list"},
want: true,
},
{
name: "Checks attributes with wildcards",
claims: &auth.Claims{
Attributes: []string{"package:*", "storage:*"},
},
attributes: []string{"package:list:something", "storage:download"},
want: true,
},
{
name: "Checks attributes with all wildcard",
claims: &auth.Claims{
Attributes: []string{"*"},
},
attributes: []string{"package:list", "storage:download"},
want: true,
},
{
name: "Checks missing attributes with wildcard",
claims: &auth.Claims{
Attributes: []string{"package:*"},
},
attributes: []string{"package:list", "storage:download"},
want: false,
},
} {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()

assert.Equal(t, tt.claims.CheckAttributes(tt.attributes), tt.want)
})
}
}
16 changes: 15 additions & 1 deletion internal/api/auth/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,14 @@ type Config struct {
type OIDCConfig struct {
ProviderURL string
ClientID string
ABAC OIDCABACConfig
}

type OIDCABACConfig struct {
Enabled bool
ClaimPath string
ClaimPathSeparator string
ClaimValuePrefix string
}

type TicketConfig struct {
Expand All @@ -26,8 +34,14 @@ type RedisConfig struct {

// Validate implements config.ConfigurationValidator.
func (c Config) Validate() error {
if c.Enabled && c.OIDC == nil {
if !c.Enabled {
return nil
}
if c.OIDC == nil || c.OIDC.ProviderURL == "" || c.OIDC.ClientID == "" {
return errors.New("Missing OIDC configuration with API auth. enabled.")
}
if c.OIDC.ABAC.Enabled && c.OIDC.ABAC.ClaimPath == "" {
return errors.New("Missing OIDC ABAC claim path with ABAC enabled.")
}
return nil
}
Loading

0 comments on commit 29a32bb

Please sign in to comment.