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

feat: Add caching for authorization checks #87

Merged
merged 8 commits into from
Jun 9, 2023
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
9 changes: 7 additions & 2 deletions .github/workflows/ci-pipeline.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@ on:
workflow_dispatch:

env:
GO_VERSION: 1.18
GO_VERSION: "1.20"
GO_LINT_VERSION: v1.51.2
NODE_VERSION: 16
ARTIFACT_RETENTION_DAYS: 7
CONTAINER_REGISTRY: ghcr.io
Expand Down Expand Up @@ -114,7 +115,11 @@ jobs:
restore-keys: gomod-

- name: Lint code
run: make lint-api
uses: golangci/golangci-lint-action@v2
with:
version: ${{ env.GO_LINT_VERSION }}
skip-go-installation: true
args: --timeout 3m --verbose api/...

- name: Run Integration Test
run: make it-test-api
Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,6 @@

# Binary directory
bin/

# Vendor directory
vendor/
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ RUN yarn app build
# ============================================================
# Build stage 2: Build API
# ============================================================
FROM golang:1.18-alpine as go-builder
FROM golang:1.20-alpine as go-builder
WORKDIR /src/api
COPY api api/
COPY go.mod .
Expand Down
1 change: 0 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,6 @@ init-dep-ui:
.PHONY: init-dep-api
init-dep-api:
@echo "> Initializing API dependencies ..."
@cd ${API_PATH} && go mod tidy -v
@cd ${API_PATH} && go get -v ./...

# ============================================================
Expand Down
2 changes: 1 addition & 1 deletion api.Dockerfile
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
FROM golang:1.18-alpine as go-builder
FROM golang:1.20-alpine as go-builder

WORKDIR /src/api

Expand Down
12 changes: 8 additions & 4 deletions api/api/router.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,10 +40,14 @@ type AppContext struct {
func NewAppContext(db *gorm.DB, cfg *config.Config) (ctx *AppContext, err error) {
var authEnforcer enforcer.Enforcer
if cfg.Authorization.Enabled {
authEnforcer, err = enforcer.NewEnforcerBuilder().
URL(cfg.Authorization.KetoServerURL).
Product("mlp").
Build()
enforcerCfg := enforcer.NewEnforcerBuilder().URL(cfg.Authorization.KetoServerURL).Product("mlp")
if cfg.Authorization.Caching.Enabled {
enforcerCfg = enforcerCfg.WithCaching(
cfg.Authorization.Caching.KeyExpirySeconds,
cfg.Authorization.Caching.CacheCleanUpIntervalSeconds,
)
}
authEnforcer, err = enforcerCfg.Build()

if err != nil {
return nil, fmt.Errorf("failed to initialize authorization service: %v", err)
Expand Down
13 changes: 12 additions & 1 deletion api/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,14 @@ type DatabaseConfig struct {

type AuthorizationConfig struct {
Enabled bool
KetoServerURL string `validate:"required_if=Enabled True"`
KetoServerURL string `validate:"required_if=Enabled True"`
Caching *InMemoryCacheConfig `validate:"required_if=Enabled True"`
}

type InMemoryCacheConfig struct {
Enabled bool
KeyExpirySeconds int `validate:"required_if=Enabled True"`
CacheCleanUpIntervalSeconds int `validate:"required_if=Enabled True"`
}

type MlflowConfig struct {
Expand Down Expand Up @@ -188,6 +195,10 @@ var defaultConfig = &Config{

Authorization: &AuthorizationConfig{
Enabled: false,
Caching: &InMemoryCacheConfig{
KeyExpirySeconds: 600,
CacheCleanUpIntervalSeconds: 900,
},
},
Database: &DatabaseConfig{
Host: "localhost",
Expand Down
73 changes: 72 additions & 1 deletion api/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,10 @@ func TestLoad(t *testing.T) {
Environment: "dev",
Authorization: &config.AuthorizationConfig{
Enabled: false,
Caching: &config.InMemoryCacheConfig{
KeyExpirySeconds: 600,
CacheCleanUpIntervalSeconds: 900,
},
},
Database: &config.DatabaseConfig{
Host: "localhost",
Expand Down Expand Up @@ -111,6 +115,10 @@ func TestLoad(t *testing.T) {
Environment: "dev",
Authorization: &config.AuthorizationConfig{
Enabled: false,
Caching: &config.InMemoryCacheConfig{
KeyExpirySeconds: 600,
CacheCleanUpIntervalSeconds: 900,
},
},
Database: &config.DatabaseConfig{
Host: "localhost",
Expand Down Expand Up @@ -190,6 +198,11 @@ func TestLoad(t *testing.T) {
Authorization: &config.AuthorizationConfig{
Enabled: true,
KetoServerURL: "http://localhost:4466",
Caching: &config.InMemoryCacheConfig{
Enabled: true,
KeyExpirySeconds: 1000,
CacheCleanUpIntervalSeconds: 2000,
},
},
Database: &config.DatabaseConfig{
Host: "localhost",
Expand Down Expand Up @@ -275,6 +288,10 @@ func TestValidate(t *testing.T) {
Environment: "dev",
Authorization: &config.AuthorizationConfig{
Enabled: false,
Caching: &config.InMemoryCacheConfig{
KeyExpirySeconds: 600,
CacheCleanUpIntervalSeconds: 900,
},
},
Database: &config.DatabaseConfig{
Host: "localhost",
Expand Down Expand Up @@ -311,6 +328,10 @@ func TestValidate(t *testing.T) {
Authorization: &config.AuthorizationConfig{
Enabled: true,
KetoServerURL: "http://keto.mlp",
Caching: &config.InMemoryCacheConfig{
KeyExpirySeconds: 600,
CacheCleanUpIntervalSeconds: 900,
},
},
Database: &config.DatabaseConfig{
Host: "localhost",
Expand Down Expand Up @@ -350,13 +371,17 @@ func TestValidate(t *testing.T) {
"Key: 'Config.Database.Password' Error:Field validation for 'Password' failed on the 'required' tag",
),
},
"missing auth server | failure": {
"missing authz server | failure": {
config: &config.Config{
APIHost: "/v1",
Port: 8080,
Environment: "dev",
Authorization: &config.AuthorizationConfig{
Enabled: true,
Caching: &config.InMemoryCacheConfig{
KeyExpirySeconds: 600,
CacheCleanUpIntervalSeconds: 900,
},
},
Database: &config.DatabaseConfig{
Host: "localhost",
Expand Down Expand Up @@ -390,6 +415,52 @@ func TestValidate(t *testing.T) {
"Error:Field validation for 'KetoServerURL' failed on the 'required_if' tag",
),
},
"missing authz cache key expiry | failure": {
config: &config.Config{
APIHost: "/v1",
Port: 8080,
Environment: "dev",
Authorization: &config.AuthorizationConfig{
Enabled: true,
KetoServerURL: "http://abc",
Caching: &config.InMemoryCacheConfig{
Enabled: true,
},
},
Database: &config.DatabaseConfig{
Host: "localhost",
Port: 5432,
User: "mlp",
Password: "mlp",
Database: "mlp",
MigrationPath: "file://db-migrations",
},
Mlflow: &config.MlflowConfig{
TrackingURL: "http://mlflow.tracking",
},
DefaultSecretStorage: &config.SecretStorage{
Name: "default-secret-storage",
Type: "vault",
Config: models.SecretStorageConfig{
VaultConfig: &models.VaultConfig{
URL: "http://vault:8200",
Role: "my-role",
MountPath: "secret",
PathPrefix: "caraml-secret/{{ .project }}/",
AuthMethod: models.GCPAuthMethod,
GCPAuthType: models.GCEGCPAuthType,
},
},
},
},
error: errors.New(
"failed to validate configuration: " +
"Key: 'Config.Authorization.Caching.KeyExpirySeconds' " +
"Error:Field validation for 'KeyExpirySeconds' failed on the 'required_if' tag\n" +
"Key: 'Config.Authorization.Caching.CacheCleanUpIntervalSeconds' " +
"Error:Field validation for 'CacheCleanUpIntervalSeconds' failed on the 'required_if' tag",
),
},
}

for name, tt := range suite {
Expand Down
4 changes: 4 additions & 0 deletions api/config/testdata/config-2.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@ applications:
authorization:
enabled: true
ketoServerURL: http://localhost:4466
caching:
enabled: true
keyExpirySeconds: 1000
cacheCleanUpIntervalSeconds: 2000

ui:
clockworkUIHomepage: http://clockwork.dev
Expand Down
14 changes: 12 additions & 2 deletions api/middleware/authorization.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,8 +65,18 @@ func (a *Authorizer) AuthorizationMiddleware(next http.Handler) http.Handler {
}

func (a *Authorizer) getResource(r *http.Request) (string, error) {
resource := strings.Replace(strings.TrimPrefix(r.URL.Path, "/"), "/", ":", -1)
return resource, nil
parts := strings.Split(strings.TrimPrefix(r.URL.Path, "/"), "/")
// Current paths registered in MLP are of the following format:
// - /applications
// - /projects/{project_id}/**
// Given this, we only care about the permissions up-to 2 levels deep. The rationale is that
// if a user has READ/WRITE permissions on /projects/{project_id}, they would also have the same
// permissions on all its sub-resources. Thus, trimming the resource identifier to aid quicker
// authz matching and to efficiently make use of the in-memory authz cache, if enabled.
if len(parts) > 1 {
parts = parts[:2]
}
return strings.Join(parts, ":"), nil
}

func methodToAction(method string) string {
Expand Down
19 changes: 14 additions & 5 deletions api/pkg/authz/enforcer/builder.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,11 @@ import (

// Builder builder of enforcer.Enforcer
type Builder struct {
url string
product string
flavor Flavor
timeout time.Duration
url string
product string
flavor Flavor
timeout time.Duration
cacheConfig *CacheConfig
}

const (
Expand Down Expand Up @@ -54,7 +55,15 @@ func (b *Builder) Timeout(timeout time.Duration) *Builder {
return b
}

func (b *Builder) WithCaching(keyExpirySeconds int, cacheCleanUpIntervalSeconds int) *Builder {
b.cacheConfig = &CacheConfig{
KeyExpirySeconds: keyExpirySeconds,
CacheCleanUpIntervalSeconds: cacheCleanUpIntervalSeconds,
}
return b
}

// Build build an enforcer.Enforcer instance
func (b *Builder) Build() (Enforcer, error) {
return newEnforcer(b.url, b.product, b.flavor, b.timeout)
return newEnforcer(b.url, b.product, b.flavor, b.timeout, b.cacheConfig)
}
102 changes: 102 additions & 0 deletions api/pkg/authz/enforcer/cache.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
package enforcer

import (
"fmt"
"strconv"
"sync"
"time"

"github.com/ory/keto-client-go/models"
cache "github.com/patrickmn/go-cache"
)

type InMemoryCache struct {
store *cache.Cache

mapLock sync.Mutex

// Instead of caching the full names of the subject, resource and action, ids will be
// generated (using an incrementing counter) for each unique value. This will aid in
// generating smaller cache keys.
// The following maps store the mapping of the name -> internal id.
krithika369 marked this conversation as resolved.
Show resolved Hide resolved
actionMap map[string]string
resourceMap map[string]string
subjectMap map[string]string
}

func newInMemoryCache(keyExpirySeconds int, cacheCleanUpIntervalSeconds int) *InMemoryCache {
return &InMemoryCache{
store: cache.New(
time.Duration(keyExpirySeconds)*time.Second,
time.Duration(cacheCleanUpIntervalSeconds)*time.Second,
),
actionMap: map[string]string{},
resourceMap: map[string]string{},
subjectMap: map[string]string{},
}
}

func (c *InMemoryCache) LookUpPermission(input models.OryAccessControlPolicyAllowedInput) (*bool, bool) {
if cachedValue, ok := c.store.Get(c.buildCacheKey(input)); ok {
if allowed, ok := cachedValue.(*bool); ok {
return allowed, true
}
}
return nil, false
}

func (c *InMemoryCache) StorePermission(input models.OryAccessControlPolicyAllowedInput, isAllowed *bool) {
c.store.Set(c.buildCacheKey(input), isAllowed, cache.DefaultExpiration)
}

func (c *InMemoryCache) buildCacheKey(input models.OryAccessControlPolicyAllowedInput) string {
return fmt.Sprintf("%s:%s:%s",
c.getActionID(input.Action),
c.getResourceID(input.Resource),
c.getSubjectID(input.Subject),
)
}

func (c *InMemoryCache) getSubjectID(name string) string {
c.mapLock.Lock()
defer c.mapLock.Unlock()

if val, ok := c.subjectMap[name]; ok {
return val
}
newID := strconv.Itoa(countMapKeys(c.subjectMap) + 1)
c.subjectMap[name] = newID
return newID
}

func (c *InMemoryCache) getResourceID(name string) string {
c.mapLock.Lock()
defer c.mapLock.Unlock()

if val, ok := c.resourceMap[name]; ok {
return val
}
newID := strconv.Itoa(countMapKeys(c.resourceMap) + 1)
c.resourceMap[name] = newID
return newID
}

func (c *InMemoryCache) getActionID(name string) string {
c.mapLock.Lock()
defer c.mapLock.Unlock()

if val, ok := c.actionMap[name]; ok {
return val
}
newID := strconv.Itoa(countMapKeys(c.actionMap) + 1)
c.actionMap[name] = newID
return newID
}

func countMapKeys(m map[string]string) int {
count := 0
for range m {
count++
}
return count
}
Loading