├── examples ├── python │ ├── records │ │ └── .gitkeep │ ├── screenshot.png │ ├── Pipfile │ ├── policies.yaml │ ├── README.md │ ├── ui │ │ ├── style.css │ │ ├── index.html │ │ └── main.js │ ├── doorman.py │ ├── Pipfile.lock │ └── server.py └── README.md ├── docs ├── flow.png ├── requirements.txt ├── quickstart.rst ├── index.rst ├── misc.rst ├── policies.rst ├── api.rst ├── conf.py └── logo.svg ├── version.json ├── .travis.yml ├── .gitignore ├── api ├── api_test.go ├── contribute.yaml ├── api.go ├── reload.go ├── utilities.go ├── allowed.go ├── reload_test.go ├── utilities_test.go ├── authn_middleware.go ├── authn_middleware_test.go ├── allowed_test.go └── openapi.yaml ├── Dockerfile ├── authn ├── claims_test.go ├── authn_test.go ├── claims.go ├── claims_mozilla_test.go ├── claims_mozilla.go ├── authn.go ├── openid.go └── openid_test.go ├── CODE_OF_CONDUCT.md ├── README.md ├── settings_test.go ├── doorman ├── doorman_ladon_condition_principals.go ├── doorman_ladon_auditlogger.go ├── doorman.go ├── doorman_ladon.go └── doorman_ladon_test.go ├── main.go ├── Gopkg.toml ├── main_test.go ├── config ├── lint.go ├── loader.go ├── lint_test.go ├── loader_github.go ├── loader_file.go └── loader_test.go ├── settings.go ├── logger_test.go ├── sample.yaml ├── Makefile ├── logger.go ├── circle.yml ├── Gopkg.lock └── LICENSE /examples/python/records/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/flow.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mozilla/doorman/HEAD/docs/flow.png -------------------------------------------------------------------------------- /docs/requirements.txt: -------------------------------------------------------------------------------- 1 | sphinx 2 | sphinxcontrib-openapi 3 | recommonmark 4 | -------------------------------------------------------------------------------- /examples/python/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mozilla/doorman/HEAD/examples/python/screenshot.png -------------------------------------------------------------------------------- /version.json: -------------------------------------------------------------------------------- 1 | { 2 | "source":"https://github.com/mozilla/doorman", 3 | "version":"stub", 4 | "commit":"stub", 5 | "build":"stub" 6 | } 7 | 8 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: go 2 | 3 | install: 4 | - go get github.com/mattn/goveralls 5 | 6 | script: 7 | - make test-coverage 8 | - goveralls -coverprofile=coverage.txt -service=travis-ci 9 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | main 2 | api/bindata.go 3 | coverage.txt 4 | policies.yaml 5 | vendor/ 6 | node_modules/ 7 | docs/_build 8 | .venv 9 | __pycache__/ 10 | *.pyc 11 | examples/python/records/*.json 12 | -------------------------------------------------------------------------------- /examples/python/Pipfile: -------------------------------------------------------------------------------- 1 | [[source]] 2 | url = "https://pypi.python.org/simple" 3 | verify_ssl = true 4 | name = "pypi" 5 | 6 | [dev-packages] 7 | 8 | [packages] 9 | 10 | flask = "*" 11 | "flask-cors" = "*" 12 | -------------------------------------------------------------------------------- /api/api_test.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "os" 5 | "testing" 6 | 7 | "github.com/gin-gonic/gin" 8 | "github.com/mozilla/doorman/config" 9 | ) 10 | 11 | func TestMain(m *testing.M) { 12 | config.AddLoader(&config.FileLoader{}) 13 | 14 | //Set Gin to Test Mode 15 | gin.SetMode(gin.TestMode) 16 | // Run the other tests 17 | os.Exit(m.Run()) 18 | } 19 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM busybox:1.24.2 2 | 3 | WORKDIR /app 4 | 5 | RUN addgroup -g 10001 app && \ 6 | adduser -G app -u 10001 -D -h /app -s /sbin/nologin app 7 | 8 | COPY version.json /app/version.json 9 | COPY main /app/main 10 | RUN touch /etc/policies.yaml # No policy by default. 11 | 12 | USER app 13 | 14 | ENV GIN_MODE release 15 | ENV POLICIES /etc/policies.yaml 16 | ENV PORT 8000 17 | 18 | ENTRYPOINT ["/app/main"] 19 | -------------------------------------------------------------------------------- /api/contribute.yaml: -------------------------------------------------------------------------------- 1 | name: Doorman 2 | description: Doorman is an authorization micro-service 3 | repository: 4 | license: Mozilla Public Licence 2.0 5 | url: https://github.com/mozilla/doorman 6 | tests: https://travis-ci.org/mozilla/doorman/ 7 | participate: 8 | irc: irc://irc.mozilla.org/#product-delivery 9 | docs: https://github.com/mozilla/doorman 10 | irc-contacts: 11 | - Natim 12 | - mostlygeek 13 | - leplatrem 14 | keywords: 15 | - JSON 16 | - Go 17 | - product-delivery 18 | - Mozilla 19 | -------------------------------------------------------------------------------- /authn/claims_test.go: -------------------------------------------------------------------------------- 1 | package authn 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | "github.com/stretchr/testify/require" 8 | ) 9 | 10 | func TestDefaultClaimsExtractor(t *testing.T) { 11 | data := []byte(`<"sub"`) 12 | _, err := defaultExtractor.Extract(data) 13 | require.NotNil(t, err) 14 | 15 | data = []byte(`{"sub":"google-oauth2|104102306111350576628"}`) 16 | userinfo, err := defaultExtractor.Extract(data) 17 | require.Nil(t, err) 18 | assert.Equal(t, "google-oauth2|104102306111350576628", userinfo.ID) 19 | } 20 | -------------------------------------------------------------------------------- /authn/authn_test.go: -------------------------------------------------------------------------------- 1 | package authn 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | "github.com/stretchr/testify/require" 8 | ) 9 | 10 | func TestNewAuthenticator(t *testing.T) { 11 | _, err := NewAuthenticator("http://auth0.com") 12 | require.NotNil(t, err) 13 | assert.Contains(t, err.Error(), "https:// scheme") 14 | 15 | authn1, err := NewAuthenticator("https://auth0.com") 16 | require.Nil(t, err) 17 | authn2, err := NewAuthenticator("https://auth0.com") 18 | require.Nil(t, err) 19 | assert.Equal(t, authn1, authn2) 20 | 21 | other, err := NewAuthenticator("https://auth1.com") 22 | require.Nil(t, err) 23 | assert.NotEqual(t, authn1, other) 24 | } 25 | -------------------------------------------------------------------------------- /api/api.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "github.com/gin-gonic/gin" 5 | "github.com/mozilla/doorman/doorman" 6 | ) 7 | 8 | // SetupRoutes adds HTTP endpoints to the gin.Engine. 9 | func SetupRoutes(r *gin.Engine, d doorman.Doorman) { 10 | r.Use(ContextMiddleware(d)) 11 | 12 | a := r.Group("") 13 | a.Use(AuthnMiddleware(d)) 14 | a.POST("/allowed", allowedHandler) 15 | 16 | sources := d.ConfigSources() 17 | r.POST("/__reload__", reloadHandler(sources)) 18 | 19 | r.GET("/__lbheartbeat__", lbHeartbeatHandler) 20 | r.GET("/__heartbeat__", heartbeatHandler) 21 | r.GET("/__version__", versionHandler) 22 | r.GET("/__api__", YAMLAsJSONHandler("api/openapi.yaml")) 23 | r.GET("/contribute.json", YAMLAsJSONHandler("api/contribute.yaml")) 24 | } 25 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Community Participation Guidelines 2 | 3 | This repository is governed by Mozilla's code of conduct and etiquette guidelines. 4 | For more details, please read the 5 | [Mozilla Community Participation Guidelines](https://www.mozilla.org/about/governance/policies/participation/). 6 | 7 | ## How to Report 8 | For more information on how to report violations of the Community Participation Guidelines, please read our '[How to Report](https://www.mozilla.org/about/governance/policies/participation/reporting/)' page. 9 | 10 | 16 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Doorman 2 | 3 | ![](docs/logo.svg) 4 | 5 | *Doorman* is an **authorization micro-service**. 6 | 7 | [![Build Status](https://travis-ci.org/mozilla/doorman.svg?branch=master)](https://travis-ci.org/mozilla/doorman) 8 | [![Coverage Status](https://coveralls.io/repos/github/mozilla/doorman/badge.svg?branch=master)](https://coveralls.io/github/mozilla/doorman?branch=master) 9 | [![Go Report](https://goreportcard.com/badge/github.com/mozilla/doorman)](https://goreportcard.com/report/github.com/mozilla/doorman) 10 | 11 | Check out the [documentation](https://mozilla-doorman.readthedocs.io) 12 | 13 | ## License 14 | 15 | * MPLv2.0 16 | * The logo was made by Mathieu Leplatre with [Inkscape](https://inkscape.org/) 17 | and released under [CC0](https://creativecommons.org/share-your-work/public-domain/cc0/) 18 | -------------------------------------------------------------------------------- /examples/python/policies.yaml: -------------------------------------------------------------------------------- 1 | service: SLocf7Sa1ibd5GNJMMqO539g7cKvWBOI 2 | identityProvider: https://auth.mozilla.auth0.com/ 3 | policies: 4 | - id: "hello" 5 | description: Allow everyone access hello 6 | principals: 7 | - <.*> 8 | actions: 9 | - <.*> 10 | resources: 11 | - hello 12 | effect: allow 13 | - id: "record-everyone" 14 | description: Allow everyone to list, read and create records 15 | principals: 16 | - <.*> 17 | actions: 18 | - list 19 | - read 20 | - create 21 | resources: 22 | - record 23 | effect: allow 24 | - id: "record-authors" 25 | description: Allow authors to update their own record 26 | principals: 27 | - <.*> 28 | actions: 29 | - update 30 | resources: 31 | - record 32 | conditions: 33 | author: 34 | type: MatchPrincipalsCondition 35 | effect: allow 36 | -------------------------------------------------------------------------------- /api/reload.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/gin-gonic/gin" 7 | 8 | "github.com/mozilla/doorman/config" 9 | "github.com/mozilla/doorman/doorman" 10 | ) 11 | 12 | func reloadHandler(sources []string) gin.HandlerFunc { 13 | return func(c *gin.Context) { 14 | // Load files (from folders, files, Github, etc.) 15 | configs, err := config.Load(sources) 16 | if err != nil { 17 | c.JSON(http.StatusInternalServerError, gin.H{ 18 | "success": false, 19 | "message": err.Error(), 20 | }) 21 | return 22 | } 23 | 24 | // Load into Doorman. 25 | d := c.MustGet(DoormanContextKey).(doorman.Doorman) 26 | 27 | if err := d.LoadPolicies(configs); err != nil { 28 | c.JSON(http.StatusInternalServerError, gin.H{ 29 | "success": false, 30 | "message": err.Error(), 31 | }) 32 | return 33 | } 34 | 35 | c.JSON(http.StatusOK, gin.H{ 36 | "success": true, 37 | "message": "", 38 | }) 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /authn/claims.go: -------------------------------------------------------------------------------- 1 | package authn 2 | 3 | import ( 4 | "encoding/json" 5 | "github.com/pkg/errors" 6 | ) 7 | 8 | // claimExtractor is in charge of extracting meaningful info from JWT payload. 9 | type claimExtractor interface { 10 | Extract(payload []byte) (*UserInfo, error) 11 | } 12 | 13 | // claims is the set of information we extract from the JWT payload or the user 14 | // profile information. 15 | type claims struct { 16 | Subject string `json:"sub,omitempty"` 17 | Email string `json:"email,omitempty"` 18 | Groups []string `json:"groups,omitempty"` 19 | } 20 | 21 | type defaultClaimExtractor struct{} 22 | 23 | func (*defaultClaimExtractor) Extract(payload []byte) (*UserInfo, error) { 24 | var claims = &claims{} 25 | err := json.Unmarshal(payload, claims) 26 | if err != nil { 27 | return nil, errors.Wrap(err, "failed to parse user info from payload") 28 | } 29 | return &UserInfo{ 30 | ID: claims.Subject, 31 | Email: claims.Email, 32 | Groups: claims.Groups, 33 | }, nil 34 | } 35 | 36 | var defaultExtractor = &defaultClaimExtractor{} 37 | -------------------------------------------------------------------------------- /settings_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "os" 5 | "testing" 6 | 7 | "github.com/gin-gonic/gin" 8 | "github.com/sirupsen/logrus" 9 | "github.com/stretchr/testify/assert" 10 | ) 11 | 12 | func TestEnvLogLevel(t *testing.T) { 13 | var cases = []struct { 14 | mode string 15 | env string 16 | level logrus.Level 17 | }{ 18 | {gin.DebugMode, "fatal", logrus.FatalLevel}, 19 | {gin.DebugMode, "error", logrus.ErrorLevel}, 20 | {gin.DebugMode, "warn", logrus.WarnLevel}, 21 | {gin.DebugMode, "debug", logrus.DebugLevel}, 22 | {gin.DebugMode, "info", logrus.DebugLevel}, 23 | {gin.ReleaseMode, "info", logrus.InfoLevel}, 24 | } 25 | defer gin.SetMode(gin.TestMode) 26 | defer os.Unsetenv("LOG_LEVEL") 27 | for _, test := range cases { 28 | gin.SetMode(test.mode) 29 | os.Setenv("LOG_LEVEL", test.env) 30 | assert.Equal(t, test.level, levelFromEnv()) 31 | } 32 | } 33 | 34 | func TestSources(t *testing.T) { 35 | os.Setenv("POLICIES", " \tsample.yaml") 36 | defer os.Unsetenv("POLICIES") 37 | assert.Equal(t, []string{"sample.yaml"}, sources()) 38 | } 39 | -------------------------------------------------------------------------------- /examples/python/README.md: -------------------------------------------------------------------------------- 1 | # Doorman + Python API + Web UI 2 | 3 | A Web UI interacts with Auth0 and a Flask API: 4 | 5 | * Some views are protected by a Python decorator 6 | * The update view is protected by imperative code, where authors can only update their own records 7 | * The Flask API does not verify the auth token, it just passes it through to Doorman 8 | 9 | ![](screenshot.png) 10 | 11 | ## Run locally 12 | 13 | Run those three services in separate terminals: 14 | 15 | ### Doorman 16 | 17 | make serve -e POLICIES=examples/python/policies.yaml 18 | 19 | ### Flask API 20 | 21 | We use [Pipenv](https://docs.pipenv.org) to ease packages installation. 22 | 23 | cd examples/python/ 24 | pipenv install 25 | pipenv run python server.py 26 | 27 | ### Web UI 28 | 29 | Because of Auth0 configuration, we must access the Web UI on http://iam.local:3000/ 30 | 31 | Add this line to your `/etc/hosts`: 32 | 33 | 127.0.0.1 iam.local 34 | 35 | Serve the UI static files: 36 | 37 | cd examples/python/ui/ 38 | python3 -m http.server 3000 39 | 40 | Access http://iam.local:3000/ 41 | -------------------------------------------------------------------------------- /doorman/doorman_ladon_condition_principals.go: -------------------------------------------------------------------------------- 1 | package doorman 2 | 3 | import ( 4 | "github.com/ory/ladon" 5 | ) 6 | 7 | // MatchPrincipalsCondition is a condition which is fulfilled if the given value string is among principals. 8 | type MatchPrincipalsCondition struct{} 9 | 10 | // Fulfills returns true if the request's subject is equal to the given value string. 11 | // This makes sense only because we iterate on principals and set the Request subject. 12 | func (c *MatchPrincipalsCondition) Fulfills(value interface{}, r *ladon.Request) bool { 13 | s, ok := value.(string) 14 | if ok { 15 | return s == r.Subject 16 | } 17 | l, ok := value.([]string) 18 | if ok { 19 | for _, s := range l { 20 | if s == r.Subject { 21 | return true 22 | } 23 | } 24 | } 25 | return false 26 | } 27 | 28 | // GetName returns the condition's name. 29 | func (c *MatchPrincipalsCondition) GetName() string { 30 | return "MatchPrincipalsCondition" 31 | } 32 | 33 | func init() { 34 | ladon.ConditionFactories[new(MatchPrincipalsCondition).GetName()] = func() ladon.Condition { 35 | return new(MatchPrincipalsCondition) 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /authn/claims_mozilla_test.go: -------------------------------------------------------------------------------- 1 | package authn 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | "github.com/stretchr/testify/require" 8 | ) 9 | 10 | func TestMozillaClaimsExtractor(t *testing.T) { 11 | data := []byte(`<"sub"`) 12 | _, err := mozillaExtractor.Extract(data) 13 | require.NotNil(t, err) 14 | 15 | data = []byte(`{"sub":"mleplatre|Mozilla-LDAP|","email":"m@mozilla.com","https://sso.mozilla.com/claim/groups":["g1", "cloudservices_dev", "irccloud"]}`) 16 | userinfo, err := mozillaExtractor.Extract(data) 17 | require.Nil(t, err) 18 | assert.Contains(t, userinfo.ID, "|Mozilla-LDAP|") 19 | assert.Contains(t, userinfo.Email, "@mozilla.com") 20 | assert.Contains(t, userinfo.Groups, "cloudservices_dev", "irccloud") 21 | 22 | // Email provided in `email` field instead of https://sso.../emails list 23 | data = []byte(`{"sub":"mleplatre|Mozilla-LDAP","https://sso.mozilla.com/claim/emails":["m@mozilla.com"],"https://sso.mozilla.com/claim/groups":["g1", "cloudservices_dev", "irccloud"]}`) 24 | userinfo, err = mozillaExtractor.Extract(data) 25 | require.Nil(t, err) 26 | assert.Contains(t, userinfo.Email, "@mozilla.com") 27 | } 28 | -------------------------------------------------------------------------------- /examples/python/ui/style.css: -------------------------------------------------------------------------------- 1 | #error { 2 | margin: 1em; 3 | padding: 0.3em; 4 | background-color: #fab; 5 | border: #b0061e solid 1px; 6 | color: #b0061e; 7 | } 8 | .pre { 9 | font-family: monospace; 10 | white-space: pre; 11 | } 12 | 13 | /* https://css-tricks.com/functional-css-tabs-revisited/ */ 14 | .tabs { 15 | position: relative; 16 | min-height: 200px; 17 | clear: both; 18 | margin: 25px 0; 19 | } 20 | .tab { 21 | float: left; 22 | } 23 | .tab label { 24 | background: #eee; 25 | padding: 10px; 26 | border: 1px solid #ccc; 27 | margin-left: -1px; 28 | position: relative; 29 | left: 1px; 30 | } 31 | 32 | .content { 33 | position: absolute; 34 | top: 28px; 35 | left: 0; 36 | background: white; 37 | right: 0; 38 | /* bottom: 0;*/ 39 | padding: 20px; 40 | border: 1px solid #ccc; 41 | } 42 | 43 | .tab [type=radio] { 44 | display: none; 45 | } 46 | 47 | .tab [type=radio] ~ .content { 48 | display: none; 49 | } 50 | 51 | .tab [type=radio]:checked ~ .content { 52 | display: block; 53 | } 54 | 55 | [type=radio]:checked ~ label { 56 | background: white; 57 | border-bottom: 1px solid white; 58 | z-index: 2; 59 | } 60 | [type=radio]:checked ~ label ~ .content { 61 | z-index: 1; 62 | } 63 | -------------------------------------------------------------------------------- /authn/claims_mozilla.go: -------------------------------------------------------------------------------- 1 | package authn 2 | 3 | import ( 4 | "encoding/json" 5 | 6 | "github.com/pkg/errors" 7 | ) 8 | 9 | // mozillaClaims is a specific struct to extract emails and groups from 10 | // the JWT or the profile info using Mozilla specific attributes. 11 | type mozillaClaims struct { 12 | Subject string `json:"sub"` 13 | Email string `json:"email"` 14 | Emails []string `json:"https://sso.mozilla.com/claim/emails"` 15 | Groups []string `json:"https://sso.mozilla.com/claim/groups"` 16 | } 17 | 18 | type mozillaClaimExtractor struct{} 19 | 20 | func (*mozillaClaimExtractor) Extract(payload []byte) (*UserInfo, error) { 21 | var userInfo = &mozillaClaims{} 22 | err := json.Unmarshal(payload, userInfo) 23 | if err != nil { 24 | return nil, errors.Wrap(err, "failed to parse Mozilla user info from payload") 25 | } 26 | 27 | // In case the JWT was not requested with the `profile` or `email` scope, 28 | // we may not obtain the email(s). 29 | email := userInfo.Email 30 | if email == "" && len(userInfo.Emails) > 0 { 31 | email = userInfo.Emails[0] 32 | } 33 | 34 | return &UserInfo{ 35 | ID: userInfo.Subject, 36 | Email: email, 37 | Groups: userInfo.Groups, 38 | }, nil 39 | } 40 | 41 | var mozillaExtractor = &mozillaClaimExtractor{} 42 | -------------------------------------------------------------------------------- /examples/python/doorman.py: -------------------------------------------------------------------------------- 1 | import json 2 | import urllib 3 | 4 | 5 | class AuthZError(Exception): 6 | def __init__(self, error, status_code): 7 | self.error = error 8 | self.status_code = status_code 9 | 10 | 11 | def allowed(doorman, service, *, 12 | resource=None, action=None, token=None, principals=None, context=None): 13 | doorman_url = doorman + "/allowed" 14 | payload = { 15 | "resource": resource, 16 | "action": action, 17 | "principals": principals, 18 | "context": context, 19 | } 20 | body = json_dumps_ignore_none(payload) 21 | headers = { 22 | "Authorization": token or '', 23 | "Origin": service, 24 | } 25 | r = urllib.request.Request(doorman_url, data=body.encode("utf-8"), headers=headers) 26 | try: 27 | resp = urllib.request.urlopen(r) 28 | except urllib.error.HTTPError as e: 29 | raise AuthZError(e.read().decode("utf-8"), e.code) 30 | 31 | response_body = json.loads(resp.read().decode("utf-8")) 32 | 33 | if not response_body["allowed"]: 34 | raise AuthZError(response_body, 403) 35 | 36 | return response_body 37 | 38 | 39 | def json_dumps_ignore_none(d): 40 | return json.dumps({k: v for k, v in d.items() if v is not None}) 41 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | // Package main instantiantes configuration loaders, load files into a Doorman 2 | // and adds HTTP endpoints. 3 | package main 4 | 5 | import ( 6 | "github.com/gin-gonic/gin" 7 | log "github.com/sirupsen/logrus" 8 | 9 | "github.com/mozilla/doorman/api" 10 | "github.com/mozilla/doorman/config" 11 | "github.com/mozilla/doorman/doorman" 12 | ) 13 | 14 | func init() { 15 | config.AddLoader(&config.FileLoader{}) 16 | config.AddLoader(&config.GithubLoader{ 17 | Token: settings.GithubToken, 18 | }) 19 | } 20 | 21 | func setupRouter() (*gin.Engine, error) { 22 | r := gin.New() 23 | // Crash free (turns errors into 5XX). 24 | r.Use(gin.Recovery()) 25 | 26 | // Setup logging. 27 | setupLogging() 28 | r.Use(HTTPLoggerMiddleware()) 29 | 30 | // Load files (from folders, files, Github, etc.) 31 | configs, err := config.Load(settings.Sources) 32 | if err != nil { 33 | return nil, err 34 | } 35 | 36 | // Load into Doorman. 37 | d := doorman.NewDefaultLadon() 38 | if err := d.LoadPolicies(configs); err != nil { 39 | return nil, err 40 | } 41 | 42 | // Endpoints 43 | api.SetupRoutes(r, d) 44 | 45 | return r, nil 46 | } 47 | 48 | func main() { 49 | r, err := setupRouter() 50 | if err != nil { 51 | log.Fatal(err.Error()) 52 | } 53 | r.Run() // listen and serve on 0.0.0.0:$PORT (:8080) 54 | } 55 | -------------------------------------------------------------------------------- /Gopkg.toml: -------------------------------------------------------------------------------- 1 | # Gopkg.toml example 2 | # 3 | # Refer to https://github.com/golang/dep/blob/master/docs/Gopkg.toml.md 4 | # for detailed Gopkg.toml documentation. 5 | # 6 | # required = ["github.com/user/thing/cmd/thing"] 7 | # ignored = ["github.com/user/project/pkgX", "bitbucket.org/user/project/pkgA/pkgY"] 8 | # 9 | # [[constraint]] 10 | # name = "github.com/user/project" 11 | # version = "1.0.0" 12 | # 13 | # [[constraint]] 14 | # name = "github.com/user/project2" 15 | # branch = "dev" 16 | # source = "github.com/myfork/project2" 17 | # 18 | # [[override]] 19 | # name = "github.com/x/y" 20 | # version = "2.4.0" 21 | 22 | 23 | [[constraint]] 24 | name = "github.com/gin-gonic/gin" 25 | version = "1.2.0" 26 | 27 | [[constraint]] 28 | name = "github.com/ory/ladon" 29 | version = "0.8.5" 30 | 31 | [[constraint]] 32 | name = "github.com/sirupsen/logrus" 33 | version = "1.0.3" 34 | 35 | [[constraint]] 36 | name = "github.com/stretchr/testify" 37 | version = "1.1.4" 38 | 39 | [[constraint]] 40 | branch = "master" 41 | name = "go.mozilla.org/mozlogrus" 42 | 43 | [[constraint]] 44 | name = "gopkg.in/square/go-jose.v2" 45 | version = "2.1.3" 46 | 47 | [[constraint]] 48 | name = "github.com/allegro/bigcache" 49 | version = "1.0.0" 50 | 51 | [[constraint]] 52 | name = "github.com/pkg/errors" 53 | version = "0.8.0" 54 | -------------------------------------------------------------------------------- /authn/authn.go: -------------------------------------------------------------------------------- 1 | // Package authn is in charge authenticating requests. 2 | // 3 | // Authenticators will be instantiated per identity provider URI. 4 | // Currently only OpenID is supported. 5 | // 6 | // OpenID configuration and keys will be cached. 7 | package authn 8 | 9 | import ( 10 | "fmt" 11 | "net/http" 12 | "strings" 13 | ) 14 | 15 | // UserInfo contains the necessary attributes used in Doorman policies. 16 | type UserInfo struct { 17 | ID string 18 | Email string 19 | Groups []string 20 | } 21 | 22 | // Authenticator is in charge of authenticating requests. 23 | type Authenticator interface { 24 | ValidateRequest(*http.Request) (*UserInfo, error) 25 | } 26 | 27 | var authenticators map[string]Authenticator 28 | 29 | func init() { 30 | authenticators = map[string]Authenticator{} 31 | } 32 | 33 | // NewAuthenticator instantiates or reuses an existing one for the specified 34 | // identity provider. 35 | func NewAuthenticator(idP string) (Authenticator, error) { 36 | if !strings.HasPrefix(idP, "https://") { 37 | return nil, fmt.Errorf("identify provider %q does not use the https:// scheme", idP) 38 | } 39 | // Reuse authenticator instances. 40 | a, ok := authenticators[idP] 41 | if !ok { 42 | // Only OpenID is currently supported. 43 | a = newOpenIDAuthenticator(idP) 44 | authenticators[idP] = a 45 | } 46 | return a, nil 47 | } 48 | -------------------------------------------------------------------------------- /main_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "io/ioutil" 5 | "os" 6 | "testing" 7 | 8 | "github.com/gin-gonic/gin" 9 | "github.com/stretchr/testify/assert" 10 | "github.com/stretchr/testify/require" 11 | ) 12 | 13 | func TestMain(m *testing.M) { 14 | //Set Gin to Test Mode 15 | gin.SetMode(gin.TestMode) 16 | // Run the other tests 17 | os.Exit(m.Run()) 18 | } 19 | 20 | func TestSetupRouter(t *testing.T) { 21 | // Empty file. 22 | _, err := setupRouter() 23 | require.NotNil(t, err) 24 | assert.Equal(t, "empty file \"policies.yaml\"", err.Error()) 25 | 26 | // Bad definition (unknown condition type). 27 | tmpfile, _ := ioutil.TempFile("", "") 28 | tmpfile.Write([]byte(` 29 | identityProvider: 30 | service: a 31 | policies: 32 | - 33 | id: "1" 34 | action: update 35 | conditions: 36 | owner: 37 | type: fantastic 38 | `)) 39 | settings.Sources = []string{tmpfile.Name()} 40 | _, err = setupRouter() 41 | require.NotNil(t, err) 42 | assert.Equal(t, "unknown condition type fantastic", err.Error()) 43 | 44 | defer func() { 45 | os.Remove(tmpfile.Name()) // clean up 46 | settings.Sources = []string{DefaultPoliciesFilename} 47 | }() 48 | 49 | // Sample file. 50 | settings.Sources = []string{"sample.yaml"} 51 | r, err := setupRouter() 52 | require.Nil(t, err) 53 | assert.Equal(t, 7, len(r.Routes())) 54 | assert.Equal(t, 3, len(r.RouterGroup.Handlers)) 55 | } 56 | -------------------------------------------------------------------------------- /config/lint.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "fmt" 5 | log "github.com/sirupsen/logrus" 6 | "strings" 7 | 8 | "github.com/mozilla/doorman/doorman" 9 | ) 10 | 11 | // lintConfigs inspects the service configuration and warns or returns an error 12 | // if something looks wrong. 13 | func lintConfigs(configs ...doorman.ServiceConfig) error { 14 | for _, config := range configs { 15 | 16 | if config.Service == "" { 17 | return fmt.Errorf("empty service in %q", config.Source) 18 | } 19 | 20 | if len(config.Policies) == 0 { 21 | log.Warningf("No policies found in %q", config.Source) 22 | } else { 23 | log.Infof("Found %d policies", len(config.Policies)) 24 | } 25 | 26 | log.Infof("Found service %q", config.Service) 27 | log.Infof("Found %d tags", len(config.Tags)) 28 | 29 | for _, policy := range config.Policies { 30 | // HTTP verbs as actions in policies. 31 | for _, action := range policy.Actions { 32 | if strings.Contains("get,put,post,delete", strings.ToLower(action)) { 33 | log.Warningf("Avoid coupling of actions with HTTP verbs (%q in %q)", policy.ID, config.Source) 34 | } 35 | } 36 | // URLs in resources 37 | for _, resource := range policy.Resources { 38 | if strings.HasPrefix(resource, "/") { 39 | log.Warningf("Avoid coupling of resources with API URIs (%q in %q)", policy.ID, config.Source) 40 | } 41 | } 42 | } 43 | } 44 | return nil 45 | } 46 | -------------------------------------------------------------------------------- /settings.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "os" 5 | "strings" 6 | 7 | "github.com/gin-gonic/gin" 8 | "github.com/sirupsen/logrus" 9 | ) 10 | 11 | // DefaultPoliciesFilename is the default policies filename. 12 | const DefaultPoliciesFilename string = "policies.yaml" 13 | 14 | var settings struct { 15 | GithubToken string 16 | Sources []string 17 | LogLevel logrus.Level 18 | } 19 | 20 | func sources() []string { 21 | // If POLICIES not specified, read ./policies.yaml 22 | env := os.Getenv("POLICIES") 23 | if env == "" { 24 | env = DefaultPoliciesFilename 25 | } 26 | sources := strings.Split(env, " ") 27 | // Filter empty strings 28 | var r []string 29 | for _, v := range sources { 30 | s := strings.TrimSpace(v) 31 | if s != "" { 32 | r = append(r, s) 33 | } 34 | } 35 | return r 36 | } 37 | 38 | func levelFromEnv() logrus.Level { 39 | logLevel := os.Getenv("LOG_LEVEL") 40 | switch logLevel { 41 | case "fatal": 42 | return logrus.FatalLevel 43 | case "error": 44 | return logrus.ErrorLevel 45 | case "warn": 46 | return logrus.WarnLevel 47 | case "debug": 48 | return logrus.DebugLevel 49 | } 50 | // Default. 51 | if gin.Mode() == gin.ReleaseMode { 52 | return logrus.InfoLevel 53 | } 54 | return logrus.DebugLevel 55 | } 56 | 57 | func init() { 58 | settings.GithubToken = os.Getenv("GITHUB_TOKEN") 59 | settings.Sources = sources() 60 | settings.LogLevel = levelFromEnv() 61 | } 62 | -------------------------------------------------------------------------------- /config/loader.go: -------------------------------------------------------------------------------- 1 | // Package config is in charge of loading policies files from disk or remote Github URL. 2 | // 3 | // It also contains the view for the __reload__ endpoint. 4 | package config 5 | 6 | import ( 7 | "fmt" 8 | 9 | "github.com/mozilla/doorman/doorman" 10 | ) 11 | 12 | // Loader is responsible for loading the policies from files, URLs, etc. 13 | type Loader interface { 14 | // CanLoad determines if the loader can handle this source. 15 | CanLoad(source string) bool 16 | // Parse and return the configs for this source. 17 | Load(source string) (doorman.ServicesConfig, error) 18 | } 19 | 20 | var loaders []Loader 21 | 22 | func init() { 23 | loaders = []Loader{} 24 | } 25 | 26 | // AddLoader allows to plug new kinds of loaders. 27 | func AddLoader(l Loader) { 28 | loaders = append(loaders, l) 29 | } 30 | 31 | // Load will load and parse the specified sources. 32 | func Load(sources []string) (doorman.ServicesConfig, error) { 33 | configs := doorman.ServicesConfig{} 34 | for _, source := range sources { 35 | loaded := false 36 | for _, loader := range loaders { 37 | if loader.CanLoad(source) { 38 | c, err := loader.Load(source) 39 | if err != nil { 40 | return nil, err 41 | } 42 | err = lintConfigs(c...) 43 | if err != nil { 44 | return nil, err 45 | } 46 | configs = append(configs, c...) 47 | loaded = true 48 | } 49 | } 50 | if !loaded { 51 | return nil, fmt.Errorf("no appropriate loader found for %q", source) 52 | } 53 | } 54 | return configs, nil 55 | } 56 | -------------------------------------------------------------------------------- /config/lint_test.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "bytes" 5 | "os" 6 | "testing" 7 | 8 | "github.com/mozilla/doorman/doorman" 9 | "github.com/sirupsen/logrus" 10 | "github.com/stretchr/testify/assert" 11 | ) 12 | 13 | func TestLintingErrors(t *testing.T) { 14 | // Empty service 15 | c := doorman.ServiceConfig{ 16 | Service: "", 17 | } 18 | err := lintConfigs(c) 19 | assert.NotNil(t, err) 20 | } 21 | 22 | func TestLintingWarnings(t *testing.T) { 23 | var buf bytes.Buffer 24 | logrus.SetOutput(&buf) 25 | defer logrus.SetOutput(os.Stdout) 26 | 27 | // Empty policies 28 | c := doorman.ServiceConfig{ 29 | Service: "abc", 30 | } 31 | err := lintConfigs(c) 32 | assert.Nil(t, err) 33 | assert.Contains(t, buf.String(), "No policies found in") 34 | buf.Reset() 35 | 36 | // HTTP verbs as actions 37 | c = doorman.ServiceConfig{ 38 | Service: "abc", 39 | Policies: doorman.Policies{ 40 | doorman.Policy{ 41 | Actions: []string{"PUT"}, 42 | }, 43 | }, 44 | } 45 | err = lintConfigs(c) 46 | assert.Nil(t, err) 47 | assert.Contains(t, buf.String(), "Avoid coupling of actions with HTTP verbs") 48 | buf.Reset() 49 | 50 | // HTTP verbs as actions 51 | c = doorman.ServiceConfig{ 52 | Service: "abc", 53 | Policies: doorman.Policies{ 54 | doorman.Policy{ 55 | Actions: []string{"read"}, 56 | Resources: []string{"/articles/<.*>"}, 57 | }, 58 | }, 59 | } 60 | err = lintConfigs(c) 61 | assert.Nil(t, err) 62 | assert.Contains(t, buf.String(), "Avoid coupling of resources with API URIs") 63 | buf.Reset() 64 | } 65 | -------------------------------------------------------------------------------- /api/utilities.go: -------------------------------------------------------------------------------- 1 | // Package utilities provides utility endpoints like heartbeat, OpenAPI, contribute, etc. 2 | package api 3 | 4 | import ( 5 | "net/http" 6 | "os" 7 | "path/filepath" 8 | 9 | "github.com/gin-gonic/gin" 10 | "gopkg.in/yaml.v2" 11 | ) 12 | 13 | // Yaml2JSON converts an unmarshalled YAML object to a JSON one. 14 | func Yaml2JSON(i interface{}) interface{} { 15 | // https://stackoverflow.com/a/40737676/141895 16 | switch x := i.(type) { 17 | case map[interface{}]interface{}: 18 | m2 := map[string]interface{}{} 19 | for k, v := range x { 20 | m2[k.(string)] = Yaml2JSON(v) 21 | } 22 | return m2 23 | case []interface{}: 24 | for i, v := range x { 25 | x[i] = Yaml2JSON(v) 26 | } 27 | } 28 | return i 29 | } 30 | 31 | // YAMLAsJSONHandler is a handler function factory to serve specified YAML file as JSON. 32 | func YAMLAsJSONHandler(filename string) gin.HandlerFunc { 33 | return func(c *gin.Context) { 34 | yamlFile, err := Asset(filename) 35 | if err != nil { 36 | c.AbortWithError(500, err) 37 | return 38 | } 39 | 40 | var body interface{} 41 | if err := yaml.Unmarshal(yamlFile, &body); err != nil { 42 | c.AbortWithError(500, err) 43 | return 44 | } 45 | 46 | body = Yaml2JSON(body) 47 | 48 | c.JSON(http.StatusOK, body) 49 | } 50 | } 51 | 52 | func lbHeartbeatHandler(c *gin.Context) { 53 | c.JSON(http.StatusOK, gin.H{ 54 | "ok": true, 55 | }) 56 | } 57 | 58 | func heartbeatHandler(c *gin.Context) { 59 | c.JSON(http.StatusOK, gin.H{}) 60 | } 61 | 62 | func versionHandler(c *gin.Context) { 63 | // Look in current working directory. 64 | here, _ := os.Getwd() 65 | versionFile := filepath.Join(here, "version.json") 66 | c.File(versionFile) 67 | } 68 | -------------------------------------------------------------------------------- /docs/quickstart.rst: -------------------------------------------------------------------------------- 1 | Quickstart 2 | ========== 3 | 4 | Policies 5 | -------- 6 | 7 | Policies are defined in YAML files for each consuming service, locally or in remote (private) Github repos, as follow: 8 | 9 | .. code-block:: YAML 10 | 11 | service: https://api.service.org 12 | identityProvider: https://api.auth0.com/ 13 | policies: 14 | - id: alice-bob-create-keys 15 | description: Alice and Bob can create keys 16 | principals: 17 | - userid:alice 18 | - userid:bob 19 | actions: 20 | - create 21 | resources: 22 | - key 23 | effect: allow 24 | - 25 | id: crud-articles 26 | description: Editors can CRUD articles 27 | principals: 28 | - role:editor 29 | actions: 30 | - create 31 | - read 32 | - delete 33 | - update 34 | resources: 35 | - article 36 | effect: allow 37 | 38 | Save it to ``config/api-policies.yaml`` for example. 39 | 40 | Run 41 | --- 42 | 43 | *Doorman* is available as a Docker image (but can also be :ref:`ran from source `). 44 | 45 | In order to read the local files from the container, we will mount the local ``config`` folder to ``/config``. 46 | We'll then use ``/config`` as the ``POLICIES`` location. 47 | 48 | .. code-block:: bash 49 | 50 | docker run \ 51 | -e POLICIES=/config \ 52 | -v ./config:/config \ 53 | -p 8000:8080 \ 54 | --name doorman \ 55 | mozilla/doorman 56 | 57 | *Doorman* is now ready to respond authorization requests on `http://localhost:8080`. See :ref:`API docs `! 58 | 59 | 60 | Examples 61 | -------- 62 | 63 | See the `examples folder `_ on Github. 64 | -------------------------------------------------------------------------------- /logger_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "net/http" 6 | "net/http/httptest" 7 | "os" 8 | 9 | "testing" 10 | "time" 11 | 12 | "github.com/gin-gonic/gin" 13 | "github.com/sirupsen/logrus" 14 | "github.com/stretchr/testify/assert" 15 | ) 16 | 17 | func TestLoggerMiddleware(t *testing.T) { 18 | c, _ := gin.CreateTestContext(httptest.NewRecorder()) 19 | c.Request, _ = http.NewRequest("GET", "/get", nil) 20 | handler := RequestSummaryLogger() 21 | 22 | var buf bytes.Buffer 23 | summaryLog.Out = &buf 24 | 25 | handler(c) 26 | 27 | summaryLog.Out = os.Stdout 28 | 29 | assert.Contains(t, buf.String(), "\"errno\":0") 30 | } 31 | 32 | func TestRequestLogFields(t *testing.T) { 33 | r, _ := http.NewRequest("GET", "/", nil) 34 | fields := RequestLogFields(r, 200, time.Duration(100)) 35 | assert.Equal(t, 200, fields["code"]) 36 | 37 | // Errno 38 | fields = RequestLogFields(r, 500, time.Duration(100)) 39 | assert.Equal(t, 999, fields["errno"]) 40 | fields = RequestLogFields(r, 400, time.Duration(100)) 41 | assert.Equal(t, 109, fields["errno"]) 42 | fields = RequestLogFields(r, 401, time.Duration(100)) 43 | assert.Equal(t, 104, fields["errno"]) 44 | fields = RequestLogFields(r, 403, time.Duration(100)) 45 | assert.Equal(t, 121, fields["errno"]) 46 | 47 | r, _ = http.NewRequest("POST", "/diff?w=1", nil) 48 | fields = RequestLogFields(r, 200, time.Duration(100)) 49 | assert.Equal(t, "/diff?w=1", fields["path"]) 50 | } 51 | 52 | func TestSetupRouterRelease(t *testing.T) { 53 | // In release mode, we enable RequestSummaryLogger middleware. 54 | gin.SetMode(gin.ReleaseMode) 55 | defer gin.SetMode(gin.TestMode) 56 | setupRouter() 57 | 58 | var buf bytes.Buffer 59 | logrus.SetOutput(&buf) 60 | defer logrus.SetOutput(os.Stdout) 61 | 62 | logrus.Info("Haha") 63 | 64 | assert.Contains(t, buf.String(), "\"msg\":\"Haha\"") 65 | } 66 | -------------------------------------------------------------------------------- /sample.yaml: -------------------------------------------------------------------------------- 1 | service: https://sample.yaml 2 | identityProvider: 3 | tags: 4 | admins: 5 | - userid:maria 6 | policies: 7 | - 8 | id: "1" 9 | description: This policy allows 'userid:foo' to update any resource 10 | principals: 11 | - userid:foo 12 | - tag:admins 13 | actions: 14 | - update 15 | resources: 16 | - <.*> 17 | effect: allow 18 | - 19 | id: "2" 20 | description: This policy rejects everything from planet mars 21 | principals: 22 | - <.*> 23 | actions: 24 | - <.*> 25 | resources: 26 | - <.*> 27 | conditions: 28 | planet: 29 | type: StringEqualCondition 30 | options: 31 | equals: mars 32 | effect: deny 33 | - 34 | id: "3" 35 | description: This policy allow read from localhost 36 | principals: 37 | - <.*> 38 | actions: 39 | - read 40 | resources: 41 | - <.*> 42 | conditions: 43 | ip: 44 | type: CIDRCondition 45 | options: 46 | cidr: 127.0.0.0/8 47 | effect: allow 48 | - 49 | id: "4" 50 | description: Only owner 51 | principals: 52 | - <.*> 53 | actions: 54 | - <.*> 55 | resources: 56 | - <.*> 57 | conditions: 58 | owner: 59 | type: MatchPrincipalsCondition 60 | effect: allow 61 | - 62 | id: "5" 63 | description: Admins on Mozilla domain 64 | principals: 65 | - group:admins 66 | actions: 67 | - create 68 | resources: 69 | - <.*> 70 | conditions: 71 | domain: 72 | type: StringMatchCondition 73 | options: 74 | matches: .*\.mozilla\.org 75 | effect: allow 76 | - 77 | id: "6" 78 | description: Editors can update PTO 79 | principals: 80 | - role:editor 81 | actions: 82 | - update 83 | resources: 84 | - pto 85 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | Welcome to Doorman's documentation! 2 | =================================== 3 | 4 | .. image:: logo.svg 5 | 6 | *Doorman* is an **authorization micro-service** that allows to checks if an arbitrary subject is allowed to perform an action on a resource, based on a set of rules (policies). 7 | 8 | Having a centralized access control service has several advantages: 9 | 10 | - it clearly dissociates authentication from authorization 11 | - it provides a standard and generic permissions system to services developers 12 | - it facilitates permissions management across services (eg. makes revocation easier) 13 | - it allows authorizations monitoring, metrics, anomaly detection 14 | 15 | 16 | Workflow 17 | ======== 18 | 19 | .. image:: flow.png 20 | 21 | It relies on `OpenID Connect `_ to authenticate requests. The policies are defined per service and loaded in memory. Authorization requests are logged out. 22 | 23 | When a service takes advantage of *Doorman*, a typical workflow is: 24 | 25 | #. Users obtain an access token from an Identity Provider (eg. Auth0) 26 | #. They use it to call a service API endpoint 27 | #. The service posts an authorization request on *Doorman* to check if the user is allowed to perform a specific action 28 | #. *Doorman* uses the ``Origin`` request header to select the set of policies to match 29 | #. *Doorman* fetches the user infos using the provided access token and builds a list of strings (*principals*) to characterize this user 30 | #. *Doorman* matches the policies and returns if allowed or not, along with the list of principals 31 | #. Based on the *Doorman* response, the service denies the original request or executes it 32 | 33 | 34 | Contents 35 | ======== 36 | 37 | .. toctree:: 38 | :maxdepth: 2 39 | 40 | quickstart 41 | policies 42 | api 43 | misc 44 | 45 | Indices and tables 46 | ================== 47 | 48 | * :ref:`genindex` 49 | * :ref:`modindex` 50 | * :ref:`search` 51 | -------------------------------------------------------------------------------- /api/allowed.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/gin-gonic/gin" 7 | "github.com/mozilla/doorman/doorman" 8 | ) 9 | 10 | func allowedHandler(c *gin.Context) { 11 | if c.Request.ContentLength == 0 { 12 | c.JSON(http.StatusBadRequest, gin.H{ 13 | "message": "Missing body", 14 | }) 15 | return 16 | } 17 | 18 | var r doorman.Request 19 | if err := c.BindJSON(&r); err != nil { 20 | c.JSON(http.StatusBadRequest, gin.H{ 21 | "message": err.Error(), 22 | }) 23 | return 24 | } 25 | 26 | // Is authentication verification enable for this service? 27 | // If disabled (like in tests), principals can be posted in JSON. 28 | principals, ok := c.Get(PrincipalsContextKey) 29 | if ok { 30 | if len(r.Principals) > 0 { 31 | c.JSON(http.StatusBadRequest, gin.H{ 32 | "message": "cannot submit principals with authentication enabled", 33 | }) 34 | return 35 | } 36 | r.Principals = principals.(doorman.Principals) 37 | } else { 38 | if len(r.Principals) == 0 { 39 | c.JSON(http.StatusBadRequest, gin.H{ 40 | "message": "missing principals", 41 | }) 42 | return 43 | } 44 | } 45 | 46 | d := c.MustGet(DoormanContextKey).(doorman.Doorman) 47 | service := c.Request.Header.Get("Origin") 48 | 49 | // Expand principals with local ones. 50 | r.Principals = d.ExpandPrincipals(service, r.Principals) 51 | // Expand principals with specified roles. 52 | r.Principals = append(r.Principals, r.Roles()...) 53 | 54 | // Force some context values (for Audit logger mainly) 55 | // XXX: using the context field to pass custom values on *ladon.Request 56 | // for audit logging is not very elegant. 57 | if r.Context == nil { 58 | r.Context = doorman.Context{} 59 | } 60 | r.Context["remoteIP"] = c.Request.RemoteAddr 61 | r.Context["_service"] = service 62 | r.Context["_principals"] = r.Principals 63 | 64 | allowed := d.IsAllowed(service, &r) 65 | 66 | c.JSON(http.StatusOK, gin.H{ 67 | "allowed": allowed, 68 | "principals": r.Principals, 69 | }) 70 | } 71 | -------------------------------------------------------------------------------- /api/reload_test.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "encoding/json" 5 | "io/ioutil" 6 | "net/http" 7 | "net/http/httptest" 8 | "os" 9 | "testing" 10 | 11 | "github.com/gin-gonic/gin" 12 | "github.com/stretchr/testify/assert" 13 | 14 | "github.com/mozilla/doorman/doorman" 15 | ) 16 | 17 | type ReloadResponse struct { 18 | Success bool 19 | Message string 20 | } 21 | 22 | func TestReloadHandler(t *testing.T) { 23 | reloadReq, _ := http.NewRequest("POST", "/__reload__", nil) 24 | var resp ReloadResponse 25 | 26 | tmpfile, _ := ioutil.TempFile("", "") 27 | defer os.Remove(tmpfile.Name()) // clean up 28 | 29 | tmpfile.Write([]byte(` 30 | identityProvider: 31 | service: a 32 | policies: 33 | - 34 | id: "1" 35 | action: update 36 | `)) 37 | 38 | d := doorman.NewDefaultLadon() 39 | handler := reloadHandler([]string{tmpfile.Name()}) 40 | 41 | // Reload same file twice. 42 | for i := 0; i < 2; i++ { 43 | w := httptest.NewRecorder() 44 | c, _ := gin.CreateTestContext(w) 45 | c.Set(DoormanContextKey, d) 46 | c.Request = reloadReq 47 | 48 | handler(c) 49 | 50 | json.Unmarshal(w.Body.Bytes(), &resp) 51 | assert.True(t, resp.Success) 52 | } 53 | 54 | // Reload bad file. 55 | tmpfile.Write([]byte("*some$bad@cont\tent")) 56 | 57 | w := httptest.NewRecorder() 58 | c, _ := gin.CreateTestContext(w) 59 | c.Set(DoormanContextKey, d) 60 | c.Request = reloadReq 61 | 62 | handler(c) 63 | 64 | json.Unmarshal(w.Body.Bytes(), &resp) 65 | assert.Equal(t, w.Code, 500) 66 | assert.False(t, resp.Success) 67 | assert.Contains(t, resp.Message, "did not find expected alphabetic or numeric character") 68 | 69 | // Reload bad definition (unknown condition type). 70 | tmpfile.Write([]byte(` 71 | service: a 72 | policies: 73 | - 74 | id: "1" 75 | action: update 76 | conditions: 77 | owner: 78 | type: fantastic 79 | `)) 80 | w = httptest.NewRecorder() 81 | c, _ = gin.CreateTestContext(w) 82 | c.Set(DoormanContextKey, d) 83 | c.Request = reloadReq 84 | 85 | handler(c) 86 | 87 | assert.Equal(t, w.Code, 500) 88 | } 89 | -------------------------------------------------------------------------------- /examples/python/ui/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 |

Doorman + Python API + Web UI

11 | 12 | 13 | 14 | 15 | 16 |
17 | 18 |
19 | 20 | 21 | 22 |
23 |
24 |
25 |
26 | 27 |
28 | 29 | 30 | 31 |
32 |
33 | 34 | 35 | 36 |
37 |
38 |
39 |
40 | 41 |
42 | 43 | 44 | 45 |
46 |
47 | 48 |
49 |
50 |
51 | 52 |
53 | 54 | 55 | 56 |
57 |
58 |
59 |
60 |
61 | 62 | 63 | -------------------------------------------------------------------------------- /config/loader_github.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "io/ioutil" 7 | "net/http" 8 | "os" 9 | "regexp" 10 | 11 | log "github.com/sirupsen/logrus" 12 | 13 | "github.com/mozilla/doorman/doorman" 14 | ) 15 | 16 | type headers map[string]string 17 | 18 | // GithubLoader reads configuration from Github URLs. 19 | type GithubLoader struct { 20 | Token string 21 | } 22 | 23 | // CanLoad will return true if the URL contains github 24 | func (ghl *GithubLoader) CanLoad(url string) bool { 25 | regexpRepo, _ := regexp.Compile("^https://.*github.*/.*$") 26 | return regexpRepo.MatchString(url) 27 | } 28 | 29 | // Load downloads the URL into a temporary folder and loads it from disk 30 | func (ghl *GithubLoader) Load(source string) (doorman.ServicesConfig, error) { 31 | log.Infof("Load %q from Github", source) 32 | 33 | regexpFile, _ := regexp.Compile("^.*\\.ya?ml$") 34 | 35 | urls := []string{} 36 | // Single file URL. 37 | if regexpFile.MatchString(source) { 38 | urls = []string{source} 39 | } else { 40 | // Folder on remote repo. 41 | return nil, fmt.Errorf("loading from Github folder is not supported yet") 42 | } 43 | 44 | headers := headers{ 45 | "Authorization": fmt.Sprintf("token %s", ghl.Token), 46 | } 47 | 48 | // Load configurations. 49 | configs := doorman.ServicesConfig{} 50 | for _, url := range urls { 51 | tmpFile, err := download(url, headers) 52 | if err != nil { 53 | return nil, err 54 | } 55 | config, err := loadFile(tmpFile.Name()) 56 | if err != nil { 57 | return nil, err 58 | } 59 | config.Source = url 60 | 61 | // Only delete temp file if successful 62 | os.Remove(tmpFile.Name()) 63 | configs = append(configs, *config) 64 | } 65 | return configs, nil 66 | } 67 | 68 | func download(url string, headers headers) (*os.File, error) { 69 | f, err := ioutil.TempFile("", "doorman-policy-") 70 | if err != nil { 71 | return nil, err 72 | } 73 | defer f.Close() 74 | 75 | log.Debugf("Download %q", url) 76 | response, err := http.Get(url) 77 | if err != nil { 78 | return nil, err 79 | } 80 | defer response.Body.Close() 81 | 82 | size, err := io.Copy(f, response.Body) 83 | if err != nil { 84 | return nil, err 85 | } 86 | log.Debugf("Downloaded %dkB", size/1000) 87 | return f, nil 88 | } 89 | -------------------------------------------------------------------------------- /config/loader_file.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "fmt" 5 | "io/ioutil" 6 | "os" 7 | "path/filepath" 8 | 9 | log "github.com/sirupsen/logrus" 10 | "gopkg.in/yaml.v2" 11 | 12 | "github.com/mozilla/doorman/doorman" 13 | ) 14 | 15 | // notSpecified is a simple string to detect unspecified values while unmarshalling. 16 | const notSpecified = "N/A" 17 | 18 | // FileLoader loads from local disk (file, folder) 19 | type FileLoader struct{} 20 | 21 | // CanLoad will return true if the path exists. 22 | func (f *FileLoader) CanLoad(path string) bool { 23 | _, err := os.Stat(path) 24 | return !os.IsNotExist(err) 25 | } 26 | 27 | // Load reads the local file or scans the folder. 28 | func (f *FileLoader) Load(path string) (doorman.ServicesConfig, error) { 29 | log.Infof("Load %q locally", path) 30 | 31 | // File always exists because CanLoad() returned true. 32 | fileInfo, _ := os.Stat(path) 33 | 34 | // If path is a folder, list files. 35 | filenames := []string{path} 36 | if fileInfo.IsDir() { 37 | log.Debugf("List files in folder %q", path) 38 | fileInfos, err := ioutil.ReadDir(path) 39 | if err != nil { 40 | return nil, err 41 | } 42 | filenames = []string{} 43 | for _, fileInfo := range fileInfos { 44 | if fileInfo.IsDir() { 45 | continue 46 | } 47 | filename := filepath.Join(path, fileInfo.Name()) 48 | log.Debugf("Found %q", filename) 49 | filenames = append(filenames, filename) 50 | } 51 | } 52 | 53 | // Load configurations. 54 | configs := doorman.ServicesConfig{} 55 | for _, f := range filenames { 56 | config, err := loadFile(f) 57 | if err != nil { 58 | return nil, err 59 | } 60 | configs = append(configs, *config) 61 | } 62 | return configs, nil 63 | } 64 | 65 | func loadFile(filename string) (*doorman.ServiceConfig, error) { 66 | log.Debugf("Parse file %q", filename) 67 | fileContent, err := ioutil.ReadFile(filename) 68 | if err != nil { 69 | return nil, err 70 | } 71 | if len(fileContent) == 0 { 72 | return nil, fmt.Errorf("empty file %q", filename) 73 | } 74 | 75 | config := doorman.ServiceConfig{ 76 | IdentityProvider: notSpecified, 77 | } 78 | if err := yaml.Unmarshal(fileContent, &config); err != nil { 79 | return nil, err 80 | } 81 | if config.IdentityProvider == notSpecified { 82 | return nil, fmt.Errorf("identityProvider not specified in %q", filename) 83 | } 84 | config.Source = filename 85 | 86 | return &config, nil 87 | } 88 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | GO_LINT := $(GOPATH)/bin/golint 2 | GO_DEP := $(GOPATH)/bin/dep 3 | GO_BINDATA := $(GOPATH)/bin/go-bindata 4 | GO_PACKAGE := $(GOPATH)/src/github.com/mozilla/doorman 5 | DATA_FILES := ./api/openapi.yaml ./api/contribute.yaml 6 | SRC := *.go ./config/*.go ./api/*.go ./authn/*.go ./doorman/*.go 7 | PACKAGES := ./ ./config/ ./api/ ./authn/ ./doorman/ 8 | 9 | .PHONY: docs 10 | 11 | main: vendor api/bindata.go $(SRC) $(GO_PACKAGE) 12 | CGO_ENABLED=0 go build -o main *.go 13 | 14 | clean: 15 | rm -f main coverage.txt api/bindata.go vendor 16 | 17 | $(GOPATH): 18 | mkdir -p $(GOPATH) 19 | 20 | $(GO_PACKAGE): $(GOPATH) 21 | mkdir -p $(shell dirname ${GO_PACKAGE}) 22 | if [ ! -e $(GOPACKAGE) ]; then ln -sf $$PWD $(GO_PACKAGE); fi 23 | 24 | $(GO_DEP): $(GOPATH) $(GO_PACKAGE) 25 | go get -u github.com/golang/dep/cmd/dep 26 | 27 | $(GO_BINDATA): $(GOPATH) 28 | go get github.com/jteeuwen/go-bindata/... 29 | 30 | vendor: $(GO_DEP) Gopkg.lock Gopkg.toml 31 | $(GO_DEP) ensure 32 | 33 | api/bindata.go: $(GO_BINDATA) $(DATA_FILES) 34 | $(GO_BINDATA) -o api/bindata.go -pkg api $(DATA_FILES) 35 | 36 | policies.yaml: 37 | touch policies.yaml 38 | 39 | serve: main policies.yaml 40 | ./main 41 | 42 | $(GO_LINT): 43 | go get github.com/golang/lint/golint 44 | 45 | lint: $(GO_LINT) 46 | $(GO_LINT) $(PACKAGES) 47 | go vet $(PACKAGES) 48 | 49 | fmt: 50 | gofmt -w -s $(SRC) 51 | 52 | test: vendor policies.yaml api/bindata.go lint 53 | go test -v $(PACKAGES) 54 | 55 | test-coverage: vendor policies.yaml api/bindata.go 56 | # Multiple package coverage script from https://github.com/pierrre/gotestcover 57 | echo 'mode: atomic' > coverage.txt && go list ./... | grep -v /vendor/ | xargs -n1 -I{} sh -c 'go test -v -covermode=atomic -coverprofile=coverage.tmp {} && tail -n +2 coverage.tmp >> coverage.txt' && rm coverage.tmp 58 | # Exclude bindata.go from coverage. 59 | sed -i '/bindata.go/d' coverage.txt 60 | 61 | docker-build: main 62 | docker build -t mozilla/doorman . 63 | 64 | docker-run: 65 | docker run --name doorman --rm mozilla/doorman 66 | 67 | .venv/bin/sphinx-build: 68 | virtualenv .venv 69 | .venv/bin/pip install -r docs/requirements.txt 70 | 71 | docs: .venv/bin/sphinx-build docs/*.rst 72 | .venv/bin/sphinx-build -a -W -n -b html -d docs/_build/doctrees docs docs/_build/html 73 | @echo 74 | @echo "Build finished. The HTML pages are in $(SPHINX_BUILDDIR)/html/index.html" 75 | 76 | docs-publish: docs 77 | # https://github.com/tschaub/gh-pages 78 | gh-pages -d api-docs 79 | -------------------------------------------------------------------------------- /api/utilities_test.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "encoding/json" 5 | "io" 6 | "io/ioutil" 7 | "net/http" 8 | "net/http/httptest" 9 | "os" 10 | "testing" 11 | 12 | "github.com/gin-gonic/gin" 13 | "github.com/mozilla/doorman/doorman" 14 | "github.com/stretchr/testify/assert" 15 | "github.com/stretchr/testify/require" 16 | ) 17 | 18 | func performRequest(r http.Handler, method, path string, body io.Reader) *httptest.ResponseRecorder { 19 | req, _ := http.NewRequest(method, path, body) 20 | req.Header.Set("Content-Type", "application/json") 21 | req.Header.Set("Origin", "https://sample.yaml") 22 | w := httptest.NewRecorder() 23 | r.ServeHTTP(w, req) 24 | return w 25 | } 26 | 27 | func testJSONResponse(t *testing.T, url string, response interface{}) *httptest.ResponseRecorder { 28 | r := gin.New() 29 | SetupRoutes(r, doorman.NewDefaultLadon()) 30 | w := performRequest(r, "GET", url, nil) 31 | 32 | assert.Equal(t, http.StatusOK, w.Code) 33 | err := json.Unmarshal(w.Body.Bytes(), &response) 34 | require.Nil(t, err) 35 | 36 | return w 37 | } 38 | 39 | func TestLBHeartbeat(t *testing.T) { 40 | type Response struct { 41 | Ok bool 42 | } 43 | var response Response 44 | testJSONResponse(t, "/__lbheartbeat__", &response) 45 | 46 | assert.True(t, response.Ok) 47 | } 48 | 49 | func TestHeartbeat(t *testing.T) { 50 | type Response struct { 51 | } 52 | var response Response 53 | testJSONResponse(t, "/__heartbeat__", &response) 54 | } 55 | 56 | func TestVersion(t *testing.T) { 57 | // HTTP 404 if not found in current dir 58 | r := gin.New() 59 | SetupRoutes(r, doorman.NewDefaultLadon()) 60 | w := performRequest(r, "GET", "/__version__", nil) 61 | assert.Equal(t, w.Code, http.StatusNotFound) 62 | 63 | // Copy to ./api/ 64 | data, _ := ioutil.ReadFile("../version.json") 65 | ioutil.WriteFile("version.json", data, 0644) 66 | defer os.Remove("version.json") 67 | 68 | type Response struct { 69 | Commit string 70 | } 71 | var response Response 72 | testJSONResponse(t, "/__version__", &response) 73 | assert.Equal(t, response.Commit, "stub") 74 | } 75 | 76 | func TestOpenAPI(t *testing.T) { 77 | type Response struct { 78 | Openapi string 79 | } 80 | var response Response 81 | testJSONResponse(t, "/__api__", &response) 82 | 83 | assert.Equal(t, response.Openapi, "2.0.0") 84 | } 85 | 86 | func TestContribute(t *testing.T) { 87 | type Response struct { 88 | Name string 89 | } 90 | var response Response 91 | testJSONResponse(t, "/contribute.json", &response) 92 | 93 | assert.Equal(t, response.Name, "Doorman") 94 | } 95 | -------------------------------------------------------------------------------- /doorman/doorman_ladon_auditlogger.go: -------------------------------------------------------------------------------- 1 | package doorman 2 | 3 | import ( 4 | "os" 5 | 6 | "github.com/ory/ladon" 7 | "github.com/sirupsen/logrus" 8 | "go.mozilla.org/mozlogrus" 9 | ) 10 | 11 | type auditLogger struct { 12 | logger *logrus.Logger 13 | } 14 | 15 | func newAuditLogger() *auditLogger { 16 | authzLog := &logrus.Logger{ 17 | Out: os.Stdout, 18 | Formatter: &mozlogrus.MozLogFormatter{LoggerName: "doorman", Type: "request.authorization"}, 19 | Hooks: make(logrus.LevelHooks), 20 | Level: logrus.InfoLevel, 21 | } 22 | return &auditLogger{logger: authzLog} 23 | } 24 | 25 | func (a *auditLogger) logRequest(allowed bool, r *ladon.Request, policies ladon.Policies) { 26 | policiesNames := []string{} 27 | for _, p := range policies { 28 | policiesNames = append(policiesNames, p.GetID()) 29 | } 30 | 31 | // Remove custom values out of context for nicer logging (were set in handler) 32 | var principals Principals 33 | var service string 34 | var remoteIP string 35 | context := map[string]interface{}{} 36 | for k, v := range r.Context { 37 | if k == "_principals" { 38 | principals = v.(Principals) 39 | } else if k == "_service" { 40 | service = v.(string) 41 | } else if k == "remoteIP" { 42 | remoteIP = v.(string) 43 | } else { 44 | context[k] = v 45 | } 46 | } 47 | 48 | a.logger.WithFields( 49 | logrus.Fields{ 50 | "allowed": allowed, 51 | "principals": principals, 52 | "service": service, 53 | "remoteIP": remoteIP, 54 | "policies": policiesNames, 55 | "action": r.Action, 56 | "resource": r.Resource, 57 | "context": context, 58 | }, 59 | ).Info("") 60 | } 61 | 62 | // LogRejectedAccessRequest is called by Ladon when a request is denied. 63 | func (a *auditLogger) LogRejectedAccessRequest(request *ladon.Request, pool ladon.Policies, deciders ladon.Policies) { 64 | // Since we iterate on principals to test individual subjects, when a request is denied 65 | // we want to log the last one only, ie. when r.subject == last(principals) 66 | principals := request.Context["_principals"].(Principals) 67 | if request.Subject != principals[len(principals)-1] { 68 | return 69 | } 70 | 71 | if len(deciders) > 0 { 72 | // Explicitly denied by the last one. 73 | a.logRequest(false, request, deciders[len(deciders)-1:]) 74 | } else { 75 | // No matching policy. 76 | a.logRequest(false, request, deciders) 77 | } 78 | } 79 | 80 | // LogGrantedAccessRequest is called by Ladon when a request is granted. 81 | func (a *auditLogger) LogGrantedAccessRequest(request *ladon.Request, pool ladon.Policies, deciders ladon.Policies) { 82 | a.logRequest(true, request, deciders) 83 | } 84 | -------------------------------------------------------------------------------- /logger.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "net/http" 5 | "os" 6 | "time" 7 | 8 | "github.com/gin-gonic/gin" 9 | "github.com/sirupsen/logrus" 10 | "go.mozilla.org/mozlogrus" 11 | ) 12 | 13 | var summaryLog logrus.Logger 14 | 15 | var errorNumber = map[int]int{ 16 | http.StatusOK: 0, 17 | http.StatusUnauthorized: 104, 18 | http.StatusForbidden: 121, 19 | http.StatusBadRequest: 109, 20 | } 21 | 22 | func init() { 23 | summaryLog = logrus.Logger{ 24 | Out: os.Stdout, 25 | Formatter: &mozlogrus.MozLogFormatter{LoggerName: "doorman", Type: "request.summary"}, 26 | Hooks: make(logrus.LevelHooks), 27 | Level: logrus.InfoLevel, 28 | } 29 | } 30 | 31 | func setupLogging() { 32 | logrus.StandardLogger().SetLevel(settings.LogLevel) 33 | if gin.Mode() == gin.ReleaseMode { 34 | mozlogrus.EnableFormatter(&mozlogrus.MozLogFormatter{LoggerName: "doorman", Type: "app.log"}) 35 | } 36 | } 37 | 38 | // HTTPLoggerMiddleware will log HTTP requests. 39 | func HTTPLoggerMiddleware() gin.HandlerFunc { 40 | // For release mode, we log requests in JSON with Moz format. 41 | if gin.Mode() != gin.ReleaseMode { 42 | // Default Gin debug log. 43 | return gin.Logger() 44 | } 45 | return RequestSummaryLogger() 46 | } 47 | 48 | // RequestSummaryLogger is a Gin middleware to log request summary following Mozilla Log format. 49 | func RequestSummaryLogger() gin.HandlerFunc { 50 | return func(c *gin.Context) { 51 | // Start timer 52 | start := time.Now() 53 | 54 | // Execute view. 55 | c.Next() 56 | 57 | // Stop timer 58 | end := time.Now() 59 | latency := end.Sub(start) 60 | 61 | summaryLog.WithFields( 62 | RequestLogFields(c.Request, c.Writer.Status(), latency), 63 | ).Info("") 64 | } 65 | } 66 | 67 | // RequestLogFields returns the log fields from the request attributes. 68 | func RequestLogFields(r *http.Request, statusCode int, latency time.Duration) logrus.Fields { 69 | path := r.URL.Path 70 | raw := r.URL.RawQuery 71 | if raw != "" { 72 | path = path + "?" + raw 73 | } 74 | // Error number. 75 | errno, defined := errorNumber[statusCode] 76 | if !defined { 77 | errno = 999 78 | } 79 | 80 | // See https://github.com/mozilla-services/go-mozlogrus/issues/5 81 | return logrus.Fields{ 82 | "remoteAddress": r.RemoteAddr, 83 | "remoteAddressChain": [1]string{r.Header.Get("X-Forwarded-For")}, 84 | "method": r.Method, 85 | "agent": r.Header.Get("User-Agent"), 86 | "code": statusCode, 87 | "path": path, 88 | "errno": errno, 89 | "lang": r.Header.Get("Accept-Language"), 90 | "t": latency / time.Millisecond, 91 | "uid": nil, // user id 92 | "rid": nil, // request id 93 | "service": "", 94 | "context": "", 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /api/authn_middleware.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | 7 | "github.com/gin-gonic/gin" 8 | 9 | "github.com/mozilla/doorman/authn" 10 | "github.com/mozilla/doorman/doorman" 11 | ) 12 | 13 | // DoormanContextKey is the Gin context key to obtain the *Doorman instance. 14 | const DoormanContextKey string = "doorman" 15 | 16 | // PrincipalsContextKey is the Gin context key to obtain the current user principals. 17 | const PrincipalsContextKey string = "principals" 18 | 19 | // ContextMiddleware adds the Doorman instance to the Gin context. 20 | func ContextMiddleware(d doorman.Doorman) gin.HandlerFunc { 21 | return func(c *gin.Context) { 22 | c.Set(DoormanContextKey, d) 23 | c.Next() 24 | } 25 | } 26 | 27 | // AuthnMiddleware relies on the authenticator if authentication was enabled 28 | // for the origin. 29 | func AuthnMiddleware(d doorman.Doorman) gin.HandlerFunc { 30 | return func(c *gin.Context) { 31 | // The service requesting must send its location. It will be compared 32 | // with the services defined in policies files. 33 | // XXX: The Origin request header might not be the best choice. 34 | origin := c.Request.Header.Get("Origin") 35 | if origin == "" { 36 | c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{ 37 | "message": "Missing `Origin` request header", 38 | }) 39 | return 40 | } 41 | 42 | // Check if authentication was configured for this service. 43 | authenticator, err := d.Authenticator(origin) 44 | if err != nil { 45 | // Unknown service 46 | c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{ 47 | "message": "Unknown service specified in `Origin`", 48 | }) 49 | return 50 | } 51 | // No authenticator configured for this service. 52 | if authenticator == nil { 53 | // Do nothing. The principals list will be empty. 54 | c.Next() 55 | return 56 | } 57 | 58 | // Validate authentication. 59 | userInfo, err := authenticator.ValidateRequest(c.Request) 60 | if err != nil { 61 | c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{ 62 | "message": err.Error(), 63 | }) 64 | return 65 | } 66 | 67 | principals := buildPrincipals(userInfo) 68 | 69 | c.Set(PrincipalsContextKey, principals) 70 | 71 | c.Next() 72 | } 73 | } 74 | 75 | func buildPrincipals(userInfo *authn.UserInfo) doorman.Principals { 76 | // Extract principals from JWT 77 | var principals doorman.Principals 78 | userid := fmt.Sprintf("userid:%s", userInfo.ID) 79 | principals = append(principals, userid) 80 | 81 | // Main email (no alias) 82 | if userInfo.Email != "" { 83 | email := fmt.Sprintf("email:%s", userInfo.Email) 84 | principals = append(principals, email) 85 | } 86 | 87 | // Groups 88 | for _, group := range userInfo.Groups { 89 | prefixed := fmt.Sprintf("group:%s", group) 90 | principals = append(principals, prefixed) 91 | } 92 | return principals 93 | } 94 | -------------------------------------------------------------------------------- /api/authn_middleware_test.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "net/http" 5 | "net/http/httptest" 6 | "testing" 7 | 8 | "github.com/gin-gonic/gin" 9 | 10 | "github.com/stretchr/testify/assert" 11 | "github.com/stretchr/testify/mock" 12 | "github.com/stretchr/testify/require" 13 | 14 | "github.com/mozilla/doorman/authn" 15 | "github.com/mozilla/doorman/doorman" 16 | ) 17 | 18 | type TestAuthenticator struct { 19 | mock.Mock 20 | } 21 | 22 | func (v *TestAuthenticator) ValidateRequest(request *http.Request) (*authn.UserInfo, error) { 23 | args := v.Called(request) 24 | return args.Get(0).(*authn.UserInfo), args.Error(1) 25 | } 26 | 27 | func TestAuthnMiddleware(t *testing.T) { 28 | d := doorman.NewDefaultLadon() 29 | handler := AuthnMiddleware(d) 30 | 31 | audience := "https://some.api.com" 32 | 33 | // Associate a fake JWT validator to this issuer. 34 | v := &TestAuthenticator{} 35 | d.SetAuthenticator(audience, v) 36 | 37 | // Extract claims is ran on every request. 38 | claims := &authn.UserInfo{ 39 | ID: "ldap|user", 40 | Email: "user@corp.com", 41 | Groups: []string{"Employee", "Admins"}, 42 | } 43 | v.On("ValidateRequest", mock.Anything).Return(claims, nil) 44 | c, _ := gin.CreateTestContext(httptest.NewRecorder()) 45 | c.Request, _ = http.NewRequest("GET", "/get", nil) 46 | c.Request.Header.Set("Origin", audience) 47 | 48 | handler(c) 49 | 50 | v.AssertCalled(t, "ValidateRequest", c.Request) 51 | 52 | // Principals are set in context. 53 | principals, ok := c.Get(PrincipalsContextKey) 54 | require.True(t, ok) 55 | assert.Equal(t, principals, doorman.Principals{ 56 | "userid:ldap|user", 57 | "email:user@corp.com", 58 | "group:Employee", 59 | "group:Admins", 60 | }) 61 | 62 | c, _ = gin.CreateTestContext(httptest.NewRecorder()) 63 | 64 | // Missing origin. 65 | c.Request, _ = http.NewRequest("GET", "/get", nil) 66 | handler(c) 67 | _, ok = c.Get(PrincipalsContextKey) 68 | assert.False(t, ok) 69 | 70 | // Wrong origin. 71 | c.Request, _ = http.NewRequest("GET", "/get", nil) 72 | c.Request.Header.Set("Origin", "https://wrong.com") 73 | handler(c) 74 | _, ok = c.Get(PrincipalsContextKey) 75 | assert.False(t, ok) 76 | 77 | // Authentication not configured for this origin. 78 | d.SetAuthenticator("https://open", nil) 79 | 80 | c.Request, _ = http.NewRequest("GET", "/get", nil) 81 | c.Request.Header.Set("Origin", "https://open") 82 | handler(c) 83 | _, ok = c.Get(PrincipalsContextKey) 84 | assert.False(t, ok) 85 | 86 | // Userinfo are set as principals in request context. 87 | claims = &authn.UserInfo{ 88 | ID: "ldap|user", 89 | } 90 | v = &TestAuthenticator{} 91 | v.On("ValidateRequest", mock.Anything).Return(claims, nil) 92 | d.SetAuthenticator(audience, v) 93 | c, _ = gin.CreateTestContext(httptest.NewRecorder()) 94 | c.Request, _ = http.NewRequest("GET", "/get", nil) 95 | c.Request.Header.Set("Origin", audience) 96 | handler(c) 97 | principals, _ = c.Get(PrincipalsContextKey) 98 | assert.Equal(t, doorman.Principals{"userid:ldap|user"}, principals) 99 | } 100 | -------------------------------------------------------------------------------- /circle.yml: -------------------------------------------------------------------------------- 1 | # These environment variables must be set in CircleCI UI 2 | # 3 | # DOCKERHUB_REPO - docker hub repo, format: / 4 | # DOCKER_EMAIL - login info for docker hub 5 | # DOCKER_USER 6 | # DOCKER_PASS 7 | # 8 | machine: 9 | environment: 10 | 11 | # make some env vars to save typing 12 | # GWS should already exists on the ubuntu trusty build image 13 | GWS: "$HOME/.go_workspace" 14 | A: "$GWS/src/github.com/$CIRCLE_PROJECT_USERNAME" 15 | B: "$A/$CIRCLE_PROJECT_REPONAME" 16 | 17 | # Use to install Custom golang from https://golang.org/dl/ 18 | GODIST: "go1.9.linux-amd64.tar.gz" 19 | GODIST_HASH: "d70eadefce8e160638a9a6db97f7192d8463069ab33138893ad3bf31b0650a79" 20 | 21 | services: 22 | - docker 23 | 24 | # install custom golang 25 | post: 26 | - mkdir -p download 27 | - test -e download/$GODIST || curl -o download/$GODIST https://storage.googleapis.com/golang/$GODIST 28 | # verify it 29 | - echo "$GODIST_HASH download/$GODIST" | sha256sum -c 30 | - sudo rm -rf /usr/local/go 31 | 32 | - sudo tar -C /usr/local -xzf download/$GODIST 33 | 34 | dependencies: 35 | cache_directories: 36 | - "~/docker" 37 | - "~/download" 38 | 39 | pre: 40 | - sudo apt-get update; sudo apt-get install pigz 41 | 42 | override: 43 | - mkdir -p $GWS/pkg $GWS/bin $A 44 | - ln -fs $HOME/$CIRCLE_PROJECT_REPONAME $A 45 | 46 | - echo 'export GOPATH=$GWS' >> ~/.circlerc 47 | 48 | - docker info 49 | 50 | # Build the container, using Circle's Docker cache. Only use 1 image per 51 | # day to keep the cache size down. 52 | - I="image-$(date +%j).gz"; if [ -e "~/docker/$I" ]; then echo "Loading $I"; pigz -d -c "~/docker/$I" | docker load || rm -f "~/docker/$I"; fi 53 | 54 | # create a version.json 55 | - > 56 | printf '{"name":"%s","commit":"%s","version":"%s","source":"https://github.com/%s/%s","build":"%s"}\n' 57 | "doorman" 58 | "$CIRCLE_SHA1" 59 | "$CIRCLE_TAG" 60 | "mozilla" 61 | "$CIRCLE_PROJECT_REPONAME" 62 | "$CIRCLE_BUILD_URL" 63 | > version.json 64 | 65 | # build the actual deployment container 66 | - go version 67 | - cd "$B" && make 68 | - docker build --pull -t app . 69 | 70 | # Clean up any old images; save the new one. 71 | - I="image-$(date +%j).gz"; mkdir -p ~/docker; rm ~/docker/*; docker save app | pigz --fast -c > ~/docker/$I; ls -l ~/docker 72 | 73 | test: 74 | override: 75 | - git checkout version.json 76 | - cd "$B" && make test 77 | 78 | # appropriately tag and push the container to dockerhub 79 | deployment: 80 | hub_latest: 81 | branch: "master" 82 | commands: 83 | - "[ ! -z $DOCKERHUB_REPO ]" 84 | - "docker login -e $DOCKER_EMAIL -u $DOCKER_USER -p $DOCKER_PASS" 85 | - "docker images" 86 | - "docker tag app ${DOCKERHUB_REPO}:latest" 87 | - "docker push ${DOCKERHUB_REPO}:latest" 88 | 89 | hub_releases: 90 | # push all tags 91 | tag: /.*/ 92 | commands: 93 | - "[ ! -z $DOCKERHUB_REPO ]" 94 | - "docker login -e $DOCKER_EMAIL -u $DOCKER_USER -p $DOCKER_PASS" 95 | - "echo ${DOCKERHUB_REPO}:${CIRCLE_TAG}" 96 | - "docker tag app ${DOCKERHUB_REPO}:${CIRCLE_TAG}" 97 | - "docker images" 98 | - "docker push ${DOCKERHUB_REPO}:${CIRCLE_TAG}" 99 | -------------------------------------------------------------------------------- /examples/python/Pipfile.lock: -------------------------------------------------------------------------------- 1 | { 2 | "_meta": { 3 | "hash": { 4 | "sha256": "f7aa6b6f4306185cc521b1df2b2db72cf6053464e44784087db3fa20208ca4f2" 5 | }, 6 | "host-environment-markers": { 7 | "implementation_name": "cpython", 8 | "implementation_version": "3.6.3", 9 | "os_name": "posix", 10 | "platform_machine": "x86_64", 11 | "platform_python_implementation": "CPython", 12 | "platform_release": "4.13.0-16-generic", 13 | "platform_system": "Linux", 14 | "platform_version": "#19-Ubuntu SMP Wed Oct 11 18:35:14 UTC 2017", 15 | "python_full_version": "3.6.3", 16 | "python_version": "3.6", 17 | "sys_platform": "linux" 18 | }, 19 | "pipfile-spec": 6, 20 | "requires": {}, 21 | "sources": [ 22 | { 23 | "name": "pypi", 24 | "url": "https://pypi.python.org/simple", 25 | "verify_ssl": true 26 | } 27 | ] 28 | }, 29 | "default": { 30 | "click": { 31 | "hashes": [ 32 | "sha256:29f99fc6125fbc931b758dc053b3114e55c77a6e4c6c3a2674a2dc986016381d", 33 | "sha256:f15516df478d5a56180fbf80e68f206010e6d160fc39fa508b65e035fd75130b" 34 | ], 35 | "version": "==6.7" 36 | }, 37 | "flask": { 38 | "hashes": [ 39 | "sha256:0749df235e3ff61ac108f69ac178c9770caeaccad2509cb762ce1f65570a8856", 40 | "sha256:49f44461237b69ecd901cc7ce66feea0319b9158743dd27a2899962ab214dac1" 41 | ], 42 | "version": "==0.12.2" 43 | }, 44 | "flask-cors": { 45 | "hashes": [ 46 | "sha256:55eb3864b4290f939ff19a2250fcb47d8c0f63d250c6780b922629299d011ec8", 47 | "sha256:ac4b81b3d90f5f18714c995c807f94501df8c9bbc22ef4261c1cd850748c3850", 48 | "sha256:62ebc5ad80dc21ca0ea9f57466c2c74e24a62274af890b391790c260eb7b754b" 49 | ], 50 | "version": "==3.0.3" 51 | }, 52 | "itsdangerous": { 53 | "hashes": [ 54 | "sha256:cbb3fcf8d3e33df861709ecaf89d9e6629cff0a217bc2848f1b41cd30d360519" 55 | ], 56 | "version": "==0.24" 57 | }, 58 | "jinja2": { 59 | "hashes": [ 60 | "sha256:74c935a1b8bb9a3947c50a54766a969d4846290e1e788ea44c1392163723c3bd", 61 | "sha256:f84be1bb0040caca4cea721fcbbbbd61f9be9464ca236387158b0feea01914a4" 62 | ], 63 | "version": "==2.10" 64 | }, 65 | "markupsafe": { 66 | "hashes": [ 67 | "sha256:a6be69091dac236ea9c6bc7d012beab42010fa914c459791d627dad4910eb665" 68 | ], 69 | "version": "==1.0" 70 | }, 71 | "six": { 72 | "hashes": [ 73 | "sha256:832dc0e10feb1aa2c68dcc57dbb658f1c7e65b9b61af69048abc87a2db00a0eb", 74 | "sha256:70e8a77beed4562e7f14fe23a786b54f6296e34344c23bc42f07b15018ff98e9" 75 | ], 76 | "version": "==1.11.0" 77 | }, 78 | "werkzeug": { 79 | "hashes": [ 80 | "sha256:e8549c143af3ce6559699a01e26fa4174f4c591dbee0a499f3cd4c3781cdec3d", 81 | "sha256:903a7b87b74635244548b30d30db4c8947fe64c5198f58899ddcd3a13c23bb26" 82 | ], 83 | "version": "==0.12.2" 84 | } 85 | }, 86 | "develop": {} 87 | } 88 | -------------------------------------------------------------------------------- /docs/misc.rst: -------------------------------------------------------------------------------- 1 | Misc 2 | ==== 3 | 4 | .. _misc-run-source: 5 | 6 | Run from source 7 | --------------- 8 | 9 | .. code-block:: bash 10 | 11 | make serve -e "POLICIES=sample.yaml /etc/doorman" 12 | 13 | 14 | Run tests 15 | --------- 16 | 17 | .. code-block:: bash 18 | 19 | make test 20 | 21 | 22 | Generate API docs 23 | ----------------- 24 | 25 | We use `Sphinx `_, therefore the Python ``virtualenv`` command is required. 26 | 27 | .. code-block:: bash 28 | 29 | make docs 30 | 31 | 32 | Build docker container 33 | ---------------------- 34 | 35 | .. code-block:: bash 36 | 37 | make docker-build 38 | 39 | 40 | Advanced settings 41 | ----------------- 42 | 43 | * ``PORT``: listen (default: ``8080``) 44 | * ``GIN_MODE``: server mode (``release`` or default ``debug``) 45 | * ``LOG_LEVEL``: logging level (``fatal|error|warn|info|debug``, default: ``info`` with ``GIN_MODE=release`` else ``debug``) 46 | * ``VERSION_FILE``: location of JSON file with version information (default: ``./version.json``) 47 | 48 | 49 | Frequently Asked Questions 50 | -------------------------- 51 | 52 | Why did you do this like that? 53 | '''''''''''''''''''''''''''''' 54 | 55 | If something puzzles you, looks bad, or is not crystal clear, we would really appreciate your feedback! Please `file an issue `_! — yes, even if you feel uncertain :) 56 | 57 | 58 | Why should I use Doorman? 59 | ''''''''''''''''''''''''' 60 | 61 | *Doorman* saves you the burden of implementing a fined-grained permission system into your service. Plus, it can centralize and track authorizations accross multiple services, which makes permissions management a lot easier. 62 | 63 | 64 | How is it different than OpenID servers (like Hydra, etc.)? 65 | ''''''''''''''''''''''''''''''''''''''''''''''''''''''''''' 66 | 67 | *Doorman* is not responsible of managing users. It relies on an Identity Provider to authenticate requests and focuses on authorization. 68 | 69 | 70 | What is the difference with my Identity Provider authorizations? 71 | '''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''' 72 | 73 | Identity Providers may have some authorization/permissions system that allow to restrict access using user groups, audience and scopes. 74 | 75 | This kind of access control is global for the whole service. *Doorman* provides advanced policies rules that can be matched per action, resource, or any domain specific context. 76 | 77 | 78 | Why YAML? 79 | ''''''''' 80 | 81 | Policies files are meant to be edited or at least reviewed by humans. And YAML is relatively human-friendly. 82 | Plus, YAML allows to add comments. 83 | 84 | 85 | Glossary 86 | -------- 87 | 88 | .. glossary:: 89 | 90 | Identity Provider 91 | An identity provider (abbreviated IdP) is a service in charge of managing identity information, and providing authentication endpoints (login forms, tokens manipulation etc.) 92 | 93 | Access Token 94 | Access Tokens 95 | An access token is an opaque string that is issued by the Identity Provider. 96 | 97 | ID Token 98 | ID Tokens 99 | The ID token is a JSON Web Token (JWT) that contains user profile information (like the user's name, email, and so forth), represented in the form of claims. 100 | 101 | Principal 102 | Principals 103 | In *Doorman*, the *principals* is the list of strings that characterize a user. It is built from the user information, tags from the policies file and roles from the authorization request. (see `Wikipedia `_) 104 | 105 | -------------------------------------------------------------------------------- /doorman/doorman.go: -------------------------------------------------------------------------------- 1 | // Package doorman is in charge of answering authorization requests by matching 2 | // a set of policies loaded in memory. 3 | // 4 | // The default implementation relies on Ladon (https://github.com/ory/ladon). 5 | package doorman 6 | 7 | import ( 8 | "fmt" 9 | 10 | "github.com/mozilla/doorman/authn" 11 | ) 12 | 13 | // Tags map tag names to principals. 14 | type Tags map[string]Principals 15 | 16 | // Condition either do or do not fulfill an access request. 17 | type Condition struct { 18 | Type string 19 | Options map[string]interface{} 20 | } 21 | 22 | // Conditions is a collection of conditions. 23 | type Conditions map[string]Condition 24 | 25 | // Policy represents an access control. 26 | type Policy struct { 27 | ID string 28 | Description string 29 | Principals []string 30 | Effect string 31 | Resources []string 32 | Actions []string 33 | Conditions Conditions 34 | } 35 | 36 | // Policies is a collection of policies. 37 | type Policies []Policy 38 | 39 | // ServiceConfig represents the policies file content. 40 | type ServiceConfig struct { 41 | Source string 42 | Service string 43 | IdentityProvider string `yaml:"identityProvider"` 44 | Tags Tags 45 | Policies Policies 46 | } 47 | 48 | // GetTags returns the tags principals for the ones specified. 49 | func (c *ServiceConfig) GetTags(principals Principals) Principals { 50 | result := Principals{} 51 | for tag, members := range c.Tags { 52 | for _, member := range members { 53 | for _, principal := range principals { 54 | if principal == member { 55 | prefixed := fmt.Sprintf("tag:%s", tag) 56 | result = append(result, prefixed) 57 | } 58 | } 59 | } 60 | } 61 | return result 62 | } 63 | 64 | // ServicesConfig is the whole set of policies files. 65 | type ServicesConfig []ServiceConfig 66 | 67 | // Context is used as request's context. 68 | type Context map[string]interface{} 69 | 70 | // Principals represent a user (userid, email, tags, ...) 71 | type Principals []string 72 | 73 | // Request is the authorization request. 74 | type Request struct { 75 | // Principals are strings that identify the user. 76 | Principals Principals 77 | // Resource is the resource that access is requested to. 78 | Resource string 79 | // Action is the action that is requested on the resource. 80 | Action string 81 | // Context is the request's environmental context. 82 | Context Context 83 | } 84 | 85 | // Roles reads the roles from request context and returns the principals. 86 | func (r *Request) Roles() Principals { 87 | p := Principals{} 88 | if roles, ok := r.Context["roles"]; ok { 89 | if rolesI, ok := roles.([]interface{}); ok { 90 | for _, roleI := range rolesI { 91 | if role, ok := roleI.(string); ok { 92 | prefixed := fmt.Sprintf("role:%s", role) 93 | p = append(p, prefixed) 94 | } 95 | } 96 | } 97 | } 98 | return p 99 | } 100 | 101 | // Doorman is the backend in charge of checking requests against policies. 102 | type Doorman interface { 103 | // LoadPolicies is responsible for loading the services configuration into memory. 104 | LoadPolicies(configs ServicesConfig) error 105 | // ConfigSources returns the list of configuration sources. 106 | ConfigSources() []string 107 | // Authenticator by service 108 | Authenticator(service string) (authn.Authenticator, error) 109 | // ExpandPrincipals looks up and add extra principals to the ones specified. 110 | ExpandPrincipals(service string, principals Principals) Principals 111 | // IsAllowed is responsible for deciding if the specified authorization is allowed for the specified service. 112 | IsAllowed(service string, request *Request) bool 113 | } 114 | -------------------------------------------------------------------------------- /config/loader_test.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "io/ioutil" 5 | "os" 6 | "path/filepath" 7 | "testing" 8 | 9 | "github.com/stretchr/testify/assert" 10 | "github.com/stretchr/testify/require" 11 | 12 | "github.com/mozilla/doorman/doorman" 13 | ) 14 | 15 | func TestMain(m *testing.M) { 16 | AddLoader(&FileLoader{}) 17 | AddLoader(&GithubLoader{ 18 | Token: "", 19 | }) 20 | 21 | // Run the other tests 22 | os.Exit(m.Run()) 23 | } 24 | 25 | func loadTempFiles(contents ...string) (doorman.ServicesConfig, error) { 26 | var filenames []string 27 | for _, content := range contents { 28 | tmpfile, _ := ioutil.TempFile("", "") 29 | defer os.Remove(tmpfile.Name()) // clean up 30 | tmpfile.Write([]byte(content)) 31 | tmpfile.Close() 32 | filenames = append(filenames, tmpfile.Name()) 33 | } 34 | return Load(filenames) 35 | } 36 | 37 | func TestLoadBadPolicies(t *testing.T) { 38 | // Missing file 39 | _, err := Load([]string{"/tmp/unknown.yaml"}) 40 | assert.NotNil(t, err) 41 | 42 | // Empty file 43 | _, err = loadTempFiles("") 44 | assert.NotNil(t, err) 45 | 46 | // Bad YAML 47 | _, err = loadTempFiles("$\\--xx") 48 | assert.NotNil(t, err) 49 | 50 | // Missing identity provider 51 | _, err = loadTempFiles(` 52 | service: 53 | policies: 54 | - 55 | id: "1" 56 | effect: allow 57 | `) 58 | assert.NotNil(t, err) 59 | 60 | // Bad policies conditions 61 | _, err = loadTempFiles(` 62 | identityProvider: 63 | service: a 64 | policies: 65 | - 66 | id: "1" 67 | conditions: 68 | - a 69 | - b 70 | `) 71 | assert.NotNil(t, err) 72 | } 73 | 74 | func TestLoadPolicies(t *testing.T) { 75 | // Service as integer 76 | configs, err := loadTempFiles(` 77 | identityProvider: 78 | service: 1 79 | policies: 80 | - 81 | id: "1" 82 | effect: allow 83 | `) 84 | assert.Nil(t, err) 85 | assert.Equal(t, configs[0].Service, "1") 86 | } 87 | 88 | func TestLoadFolder(t *testing.T) { 89 | // Create temp dir 90 | dir, err := ioutil.TempDir("", "example") 91 | assert.Nil(t, err) 92 | defer os.RemoveAll(dir) 93 | // Create subdir (to be skipped) 94 | subdir, err := ioutil.TempDir(dir, "ignored") 95 | assert.Nil(t, err) 96 | defer os.RemoveAll(subdir) 97 | 98 | // Create sample file 99 | testfile := filepath.Join(dir, "test.yaml") 100 | defer os.Remove(testfile) 101 | err = ioutil.WriteFile(testfile, []byte(` 102 | identityProvider: 103 | service: a 104 | policies: 105 | - 106 | id: "1" 107 | action: read 108 | effect: allow 109 | `), 0666) 110 | 111 | configs, err := Load([]string{dir}) 112 | assert.Nil(t, err) 113 | require.Equal(t, len(configs), 1) 114 | assert.Equal(t, len(configs[0].Policies), 1) 115 | } 116 | 117 | func TestLoadGithub(t *testing.T) { 118 | // Unsupported URL 119 | _, err := Load([]string{"https://bitbucket.org/test.yaml"}) 120 | assert.NotNil(t, err) 121 | assert.Contains(t, err.Error(), "no appropriate loader found") 122 | 123 | // Unsupported folder. 124 | _, err = Load([]string{"https://github.com/moz/ops/configs/"}) 125 | assert.NotNil(t, err) 126 | assert.Contains(t, err.Error(), "not supported") 127 | 128 | // Bad URL 129 | _, err = Load([]string{"ftp://github.com/moz/ops/config.yaml"}) 130 | assert.NotNil(t, err) 131 | 132 | // Bad file 133 | _, err = Load([]string{"https://github.com/mozilla/doorman/raw/06a2531/main.go"}) 134 | assert.NotNil(t, err) 135 | 136 | // Good URL 137 | configs, err := Load([]string{"https://github.com/mozilla/doorman/raw/f830787/sample.yaml"}) 138 | assert.Nil(t, err) 139 | require.Equal(t, len(configs), 1) 140 | assert.Equal(t, len(configs[0].Tags), 1) 141 | assert.Equal(t, len(configs[0].Policies), 6) 142 | } 143 | 144 | func TestLoadTags(t *testing.T) { 145 | configs, err := loadTempFiles(` 146 | identityProvider: 147 | service: a 148 | tags: 149 | admins: 150 | - alice@mit.edu 151 | - ldap|bob 152 | editors: 153 | - mathieu@mozilla.com 154 | policies: 155 | - 156 | id: "1" 157 | effect: allow 158 | `) 159 | assert.Nil(t, err) 160 | require.Equal(t, len(configs), 1) 161 | assert.Equal(t, len(configs[0].Tags), 2) 162 | assert.Equal(t, len(configs[0].Tags["admins"]), 2) 163 | assert.Equal(t, len(configs[0].Tags["editors"]), 1) 164 | } 165 | -------------------------------------------------------------------------------- /Gopkg.lock: -------------------------------------------------------------------------------- 1 | # This file is autogenerated, do not edit; changes may be undone by the next 'dep ensure'. 2 | 3 | 4 | [[projects]] 5 | name = "github.com/allegro/bigcache" 6 | packages = [ 7 | ".", 8 | "queue" 9 | ] 10 | revision = "aa76879d59fa5d93c43680238227dbf7a53f5c28" 11 | version = "v1.0.0" 12 | 13 | [[projects]] 14 | name = "github.com/davecgh/go-spew" 15 | packages = ["spew"] 16 | revision = "6d212800a42e8ab5c146b8ace3490ee17e5225f9" 17 | 18 | [[projects]] 19 | branch = "master" 20 | name = "github.com/gin-contrib/sse" 21 | packages = ["."] 22 | revision = "22d885f9ecc78bf4ee5d72b937e4bbcdc58e8cae" 23 | 24 | [[projects]] 25 | name = "github.com/gin-gonic/gin" 26 | packages = [ 27 | ".", 28 | "binding", 29 | "render" 30 | ] 31 | revision = "d459835d2b077e44f7c9b453505ee29881d5d12d" 32 | version = "v1.2" 33 | 34 | [[projects]] 35 | name = "github.com/golang/protobuf" 36 | packages = ["proto"] 37 | revision = "130e6b02ab059e7b717a096f397c5b60111cae74" 38 | 39 | [[projects]] 40 | branch = "master" 41 | name = "github.com/hashicorp/golang-lru" 42 | packages = [ 43 | ".", 44 | "simplelru" 45 | ] 46 | revision = "0a025b7e63adc15a622f29b0b2c4c3848243bbf6" 47 | 48 | [[projects]] 49 | name = "github.com/mattn/go-isatty" 50 | packages = ["."] 51 | revision = "a5cdd64afdee435007ee3e9f6ed4684af949d568" 52 | 53 | [[projects]] 54 | name = "github.com/ory/ladon" 55 | packages = [ 56 | ".", 57 | "compiler", 58 | "manager/memory" 59 | ] 60 | revision = "8128aeca0a774620b4f3be9fb2a1f7c53eac662c" 61 | version = "v0.8.5" 62 | 63 | [[projects]] 64 | name = "github.com/pborman/uuid" 65 | packages = ["."] 66 | revision = "e790cca94e6cc75c7064b1332e63811d4aae1a53" 67 | version = "v1.1" 68 | 69 | [[projects]] 70 | name = "github.com/pkg/errors" 71 | packages = ["."] 72 | revision = "645ef00459ed84a119197bfb8d8205042c6df63d" 73 | version = "v0.8.0" 74 | 75 | [[projects]] 76 | name = "github.com/pmezard/go-difflib" 77 | packages = ["difflib"] 78 | revision = "d8ed2627bdf02c080bf22230dbb337003b7aba2d" 79 | 80 | [[projects]] 81 | name = "github.com/sirupsen/logrus" 82 | packages = ["."] 83 | revision = "f006c2ac4710855cf0f916dd6b77acf6b048dc6e" 84 | version = "v1.0.3" 85 | 86 | [[projects]] 87 | name = "github.com/stretchr/objx" 88 | packages = ["."] 89 | revision = "cbeaeb16a013161a98496fad62933b1d21786672" 90 | 91 | [[projects]] 92 | name = "github.com/stretchr/testify" 93 | packages = [ 94 | "assert", 95 | "mock", 96 | "require" 97 | ] 98 | revision = "69483b4bd14f5845b5a1e55bca19e954e827f1d0" 99 | version = "v1.1.4" 100 | 101 | [[projects]] 102 | name = "github.com/ugorji/go" 103 | packages = ["codec"] 104 | revision = "54210f4e076c57f351166f0ed60e67d3fca57a36" 105 | 106 | [[projects]] 107 | branch = "master" 108 | name = "go.mozilla.org/mozlogrus" 109 | packages = ["."] 110 | revision = "a4ca0c1ee1cb5e580348b1e1aac9bd3e5f07f26d" 111 | 112 | [[projects]] 113 | name = "golang.org/x/crypto" 114 | packages = [ 115 | "ed25519", 116 | "ed25519/internal/edwards25519", 117 | "ssh/terminal" 118 | ] 119 | revision = "9419663f5a44be8b34ca85f08abc5fe1be11f8a3" 120 | 121 | [[projects]] 122 | name = "golang.org/x/sys" 123 | packages = [ 124 | "unix", 125 | "windows" 126 | ] 127 | revision = "6faef541c73732f438fb660a212750a9ba9f9362" 128 | 129 | [[projects]] 130 | name = "gopkg.in/go-playground/validator.v8" 131 | packages = ["."] 132 | revision = "5f1438d3fca68893a817e4a66806cea46a9e4ebf" 133 | version = "v8.18.2" 134 | 135 | [[projects]] 136 | name = "gopkg.in/square/go-jose.v2" 137 | packages = [ 138 | ".", 139 | "cipher", 140 | "json", 141 | "jwt" 142 | ] 143 | revision = "f8f38de21b4dcd69d0413faf231983f5fd6634b1" 144 | version = "v2.1.3" 145 | 146 | [[projects]] 147 | name = "gopkg.in/yaml.v2" 148 | packages = ["."] 149 | revision = "eb3733d160e74a9c7e442f435eb3bea458e1d19f" 150 | 151 | [solve-meta] 152 | analyzer-name = "dep" 153 | analyzer-version = 1 154 | inputs-digest = "701ae34e1b1d0cb8a416648b360e0c919e1e130768fc074bf1d3304f55dd8387" 155 | solver-name = "gps-cdcl" 156 | solver-version = 1 157 | -------------------------------------------------------------------------------- /examples/python/server.py: -------------------------------------------------------------------------------- 1 | # 2 | # https://auth0.com/docs/quickstart/backend/python#add-api-authorization 3 | # https://github.com/auth0-samples/auth0-python-api-samples/tree/master/00-Starter-Seed 4 | 5 | import functools 6 | import json 7 | import os 8 | 9 | from flask import Flask, request, jsonify, _app_ctx_stack 10 | from flask_cors import cross_origin 11 | import werkzeug 12 | 13 | import doorman 14 | 15 | DOORMAN_SERVER = os.getenv("DOORMAN_SERVER", "http://localhost:8080") 16 | # This service is the Auth0 API id. 17 | SERVICE = os.getenv("SERVICE", "SLocf7Sa1ibd5GNJMMqO539g7cKvWBOI") 18 | HERE = os.path.abspath(os.path.dirname(__file__)) 19 | RECORDS_PATH = os.getenv("RECORDS_PATH", os.path.join(HERE, "records")) 20 | 21 | 22 | app = Flask(__name__) 23 | 24 | allowed = functools.partial(doorman.allowed, DOORMAN_SERVER, SERVICE) 25 | 26 | 27 | @app.errorhandler(doorman.AuthZError) 28 | def handle_auth_error(ex): 29 | response = jsonify(ex.error) 30 | response.status_code = ex.status_code 31 | return response 32 | 33 | 34 | def authorized(**allowed_kw): 35 | def wrapped(f): 36 | @functools.wraps(f) 37 | def wrapper(*args, **kwargs): 38 | token = request.headers.get("Authorization", None) 39 | authz = allowed(token=token, **allowed_kw) 40 | _app_ctx_stack.top.authz = authz 41 | return f(*args, **kwargs) 42 | return wrapper 43 | return wrapped 44 | 45 | 46 | @app.route("/") 47 | @cross_origin(headers=["Content-Type", "Authorization"]) 48 | @cross_origin(headers=["Access-Control-Allow-Origin", "*"]) 49 | @authorized(resource="hello") 50 | def hello(): 51 | """A valid access token is required to access this route 52 | """ 53 | authz = _app_ctx_stack.top.authz 54 | return jsonify(authz) 55 | 56 | 57 | @app.route("/records") 58 | @cross_origin(headers=["Content-Type", "Authorization"]) 59 | @cross_origin(headers=["Access-Control-Allow-Origin", "*"]) 60 | @authorized(resource="record", action="list") 61 | def records(): 62 | authz = _app_ctx_stack.top.authz 63 | main_principal = authz["principals"][0] 64 | records = Records.list(author=main_principal) 65 | return jsonify(records) 66 | 67 | 68 | @app.route("/records/", methods=('GET', 'PUT')) 69 | @cross_origin(headers=["Content-Type", "Authorization"]) 70 | @cross_origin(headers=["Access-Control-Allow-Origin", "*"]) 71 | def record(name): 72 | token = request.headers.get("Authorization", None) 73 | 74 | record, author = Records.read(name) 75 | 76 | if request.method == "GET": 77 | action = "read" 78 | else: 79 | action = "create" if record is None else "update" 80 | 81 | # Check if allowed to perform action (will raise AuthZError if not authorized) 82 | authz = allowed(resource="record", action=action, token=token, context={"author": author}) 83 | 84 | # Return 404 if allowed to read but unknown record. 85 | if record is None and request.method == "GET": 86 | raise werkzeug.exceptions.NotFound() 87 | 88 | # Save content on PUT 89 | if request.method == "PUT": 90 | body = request.data.decode("utf-8") 91 | main_principal = authz["principals"][0] 92 | record = Records.save(name, body, main_principal) 93 | 94 | return jsonify(record) 95 | 96 | 97 | class Records: 98 | @staticmethod 99 | def list(author): 100 | all_records = [f for f in os.listdir(RECORDS_PATH) if f.endswith(".json")] 101 | all_contents = [(f, json.load(open(os.path.join(RECORDS_PATH, f)))) for f in all_records] 102 | return [{'name': f.replace('.json', ''), 'body': content['body']} 103 | for f, content in all_contents if content["author"] == author] 104 | 105 | @staticmethod 106 | def read(name): 107 | path = os.path.join(RECORDS_PATH, "{}.json".format(os.path.basename(name))) 108 | if os.path.exists(path): 109 | with open(path, 'r') as f: 110 | content = json.load(f) 111 | return content['body'], content['author'] 112 | return None, None 113 | 114 | @staticmethod 115 | def save(name, body, author): 116 | path = os.path.join(RECORDS_PATH, "{}.json".format(os.path.basename(name))) 117 | body = {'body': body, 'author': author} 118 | with open(path, 'w') as f: 119 | json.dump(body, f) 120 | return body 121 | 122 | 123 | if __name__ == "__main__": 124 | print("RECORDS_PATH", RECORDS_PATH) 125 | print("DOORMAN_SERVER", DOORMAN_SERVER) 126 | print("SERVICE", SERVICE) 127 | app.run(host="0.0.0.0", port=os.getenv("PORT", 8000)) 128 | -------------------------------------------------------------------------------- /docs/policies.rst: -------------------------------------------------------------------------------- 1 | Policies 2 | ======== 3 | 4 | Policies are defined in YAML files for each consuming service as follow: 5 | 6 | .. code-block:: YAML 7 | 8 | service: https://service.stage.net 9 | identityProvider: https://auth.mozilla.auth0.com/ 10 | tags: 11 | superusers: 12 | - userid:maria 13 | - group:admins 14 | policies: 15 | - id: authors-superusers-delete 16 | description: Authors and superusers can delete articles 17 | principals: 18 | - role:author 19 | - tag:superusers 20 | actions: 21 | - delete 22 | resources: 23 | - article 24 | effect: allow 25 | 26 | - **service**: the unique identifier of the service 27 | - **identityProvider** (*optional*): when the identify provider is not empty, *Doorman* will verify the Access Token or the ID Token provided in the authorization header to authenticate the request and obtain the subject profile information (*principals*) 28 | - **tags**: Local «groups» of principals in addition to the ones provided by the Identity Provider 29 | - **actions**: a domain-specific string representing an action that will be defined as allowed by a principal (eg. ``publish``, ``signoff``, …) 30 | - **resources**: a domain-specific string representing a resource. Preferably not a full URL to decouple from service API design (eg. `print:blackwhite:A4`, `category:homepage`, …). 31 | - **effect**: Use ``effect: deny`` to deny explicitly. Requests that don't match any rule are denied. 32 | 33 | 34 | Settings 35 | -------- 36 | 37 | Policies can be read locally or in remote (private) Github repos. 38 | 39 | Settings are set via environment variables: 40 | 41 | * ``POLICIES``: space separated locations of YAML files with policies. They can be **single files**, **folders** or **Github URLs** (default: ``./policies.yaml``) 42 | * ``GITHUB_TOKEN``: Github API token to be used when fetching policies files from private repositories 43 | 44 | .. note:: 45 | 46 | The ``Dockerfile`` contains different default values, suited for production. 47 | 48 | 49 | Principals 50 | ---------- 51 | 52 | The principals is a list of prefixed strings to refer to the «user» as the combination of ids, emails, groups, roles… 53 | 54 | Supported prefixes: 55 | 56 | * ``userid:``: provided by Identity Provider (IdP) 57 | * ``tag:``: local tags from policies file 58 | * ``role:``: provided in :ref:`context of authorization requests ` 59 | * ``email:``: provided by IdP 60 | * ``group:``: provided by IdP 61 | 62 | Example: ``["userid:ldap|user", "email:user@corp.com", "group:Employee", "group:Admins", "role:editor"]`` 63 | 64 | 65 | Advanced policies rules 66 | ----------------------- 67 | 68 | Regular expressions 69 | ''''''''''''''''''' 70 | 71 | Regular expressions begin with ``<`` and end with ``>``. 72 | 73 | .. code-block:: YAML 74 | 75 | principals: 76 | - userid:<[peter|ken]> 77 | resources: 78 | - /page/<.*> 79 | 80 | .. note:: 81 | 82 | Regular expressions are not supported in tags members definitions. 83 | 84 | .. _policies-conditions: 85 | 86 | Conditions 87 | '''''''''' 88 | 89 | The conditions are **optional** on policies and are used to match field values from the :ref:`authorization request context `. 90 | 91 | The context value ``remoteIP`` is forced by the server. 92 | 93 | For example: 94 | 95 | .. code-block:: YAML 96 | 97 | policies: 98 | - 99 | description: Allow everything from dev environment 100 | conditions: 101 | env: 102 | type: StringEqualCondition 103 | options: 104 | equals: dev 105 | 106 | There are several types of conditions: 107 | 108 | **Field comparison** 109 | 110 | * type: ``StringEqualCondition`` 111 | 112 | For example, match ``request.context["country"] == "catalunya"``: 113 | 114 | .. code-block:: YAML 115 | 116 | conditions: 117 | country: 118 | type: StringEqualCondition 119 | options: 120 | equals: catalunya 121 | 122 | **Field pattern** 123 | 124 | * type: ``StringMatchCondition`` 125 | 126 | For example, match ``request.context["bucket"] ~= "blocklists-.*"``: 127 | 128 | .. code-block:: YAML 129 | 130 | conditions: 131 | bucket: 132 | type: StringMatchCondition 133 | options: 134 | matches: blocklists-.* 135 | 136 | **Match principals** 137 | 138 | * type: ``MatchPrincipalsCondition`` 139 | 140 | For example, allow requests where ``request.context["owner"]`` is in principals: 141 | 142 | .. code-block:: YAML 143 | 144 | conditions: 145 | owner: 146 | type: MatchPrincipalsCondition 147 | 148 | .. note:: 149 | 150 | This also works when a the context field is list (e.g. list of collaborators). 151 | 152 | **IP/Range** 153 | 154 | * type: ``CIDRCondition`` 155 | 156 | For example, match ``request.context["remoteIP"]`` with [CIDR notation](https://en.wikipedia.org/wiki/Classless_Inter-Domain_Routing#CIDR_notation): 157 | 158 | .. code-block:: YAML 159 | 160 | conditions: 161 | remoteIP: 162 | type: CIDRCondition 163 | options: 164 | # mask 255.255.0.0 165 | cidr: 192.168.0.1/16 166 | -------------------------------------------------------------------------------- /docs/api.rst: -------------------------------------------------------------------------------- 1 | .. _api: 2 | 3 | API 4 | === 5 | 6 | Summary 7 | ------- 8 | 9 | Basically, authorization requests are checked using **POST /allowed**. 10 | 11 | * The ``Origin`` request header specifies the service to match policies from. 12 | * The ``Authorization`` request header provides the OpenID :term:`Access Token` to authenticate the request. 13 | 14 | **Request**: 15 | 16 | .. code-block:: HTTP 17 | 18 | POST /allowed HTTP/1.1 19 | Origin: https://api.service.org 20 | Authorization: Bearer f2457yu86yikhmbh 21 | 22 | { 23 | "action" : "delete", 24 | "resource": "articles/doorman-introduce", 25 | } 26 | 27 | **Response**: 28 | 29 | .. code-block:: HTTP 30 | 31 | HTTP/1.1 200 OK 32 | Content-Type: application/json 33 | 34 | { 35 | "allowed": true, 36 | "principals": [ 37 | "userid:ada", 38 | "email:ada.lovelace@eff.org", 39 | "group:scientists", 40 | "group:history" 41 | ] 42 | } 43 | 44 | 45 | Principals 46 | ---------- 47 | 48 | The authorization request :term:principals will be built from the user profile information like this: 49 | 50 | * ``"sub"``: ``userid:{}`` 51 | * ``"email"``: ``email:{}`` (*optional*) 52 | * ``"groups"``: ``group:{}, group:{}, ...`` (*optional*) 53 | 54 | They will be matched against those specified in the policies rules to determine if the authorization request is denied or allowed. 55 | 56 | 57 | Authentication 58 | -------------- 59 | 60 | *Doorman* relies on OpenID to authenticate requests. 61 | 62 | It will use the ``service`` and ``identityProvider`` fields from the service policies file to fetch the user profile information. 63 | 64 | The ``Origin`` request header should match one of the services defined in the policies files. 65 | 66 | The ``Authorization`` request header should contain a valid :term:`Access Token`, prefixed with ``Bearer ``. 67 | This access token must have been requested with the ``openid profile`` scope for *Doorman* to be able to fetch the profile information (See `Auth0 docs `_). 68 | 69 | The userinfo URI endpoint is then obtained from the metadata available at ``{identityProvider}/.well-known/openid-configuration``. 70 | 71 | If the obtention of user infos is denied by the :term:`Identity Provider`, the authorization request is obviously denied. 72 | 73 | 74 | Using ID tokens 75 | ''''''''''''''' 76 | 77 | *Doorman* can verify and read user information from JWT :term:`ID tokens`. Since the ID token payload contains the user information, it saves a roundtrip to the Identity Provider when handling authorization requests. 78 | 79 | For this to work, the ``service`` value in the policies file must match the ``audience`` value configured on the Identity Provider — the unique identifier of the target API. For example, in `Auth0 `_ it can look like this: ``SLocf7Sa1ibd5GNJMMqO539g7cKvWBOI``. 80 | 81 | .. important:: 82 | 83 | When using JWT :term:`ID tokens`, only the validity of the token will be checked. In other words, users that are revoked from the Identity Provider after their ID token was issued will still considered authenticated until the token expires. 84 | 85 | 86 | Without authentication 87 | '''''''''''''''''''''' 88 | 89 | If the identity provider is not configured for a service (explicit empty value), no authentication is required and the principals are posted in the authorization body. 90 | 91 | .. code-block:: HTTP 92 | 93 | POST /allowed HTTP/1.1 94 | Origin: https://api.service.org 95 | Authorization: Bearer f2457yu86yikhmbh 96 | 97 | { 98 | "action" : "delete", 99 | "resource": "articles/doorman-introduce", 100 | "principals": [ 101 | "userid:mickaeljfox", 102 | "email:mj@fox.com", 103 | "group:actors" 104 | ] 105 | } 106 | 107 | It is not especially recommended, but it can give a certain amount of flexibility when authentication is fully managed on the service. 108 | 109 | A typical workflow in this case would be: 110 | 111 | 1. Users call the service API endpoint 112 | 1. The service authenticates the user and builds the list of principals 113 | 1. The service posts an authorization request on *Doorman* containing the list of principals to check if the user is allowed 114 | 115 | 116 | .. _api-context: 117 | 118 | Context 119 | ------- 120 | 121 | Authorization requests can carry additional information contain any extra information to be matched in :ref:`policies conditions `. 122 | 123 | The values provided in the ``roles`` context field will expand the principals with extra ``role:{}`` values. 124 | 125 | .. code-block:: HTTP 126 | 127 | POST /allowed HTTP/1.1 128 | Origin: https://api.service.org 129 | Authorization: Bearer f2457yu86yikhmbh 130 | 131 | { 132 | "action" : "delete", 133 | "resource": "articles/doorman-introduce", 134 | "context": { 135 | "env", "stage", 136 | "roles": ["editor"] 137 | } 138 | } 139 | 140 | 141 | API Endpoints 142 | ------------- 143 | 144 | (Automatically generated from `the OpenAPI specs `_) 145 | 146 | .. openapi:: ../api/openapi.yaml 147 | -------------------------------------------------------------------------------- /doorman/doorman_ladon.go: -------------------------------------------------------------------------------- 1 | package doorman 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | 7 | "github.com/ory/ladon" 8 | manager "github.com/ory/ladon/manager/memory" 9 | log "github.com/sirupsen/logrus" 10 | 11 | "github.com/mozilla/doorman/authn" 12 | ) 13 | 14 | const maxInt int64 = 1<<63 - 1 15 | 16 | // LadonDoorman is the backend in charge of checking requests against policies. 17 | type LadonDoorman struct { 18 | _auditLogger *auditLogger 19 | 20 | services map[string]ServiceConfig 21 | ladons map[string]*ladon.Ladon 22 | authenticators map[string]authn.Authenticator 23 | } 24 | 25 | // NewDefaultLadon instantiates a new doorman. 26 | func NewDefaultLadon() *LadonDoorman { 27 | w := &LadonDoorman{ 28 | services: map[string]ServiceConfig{}, 29 | ladons: map[string]*ladon.Ladon{}, 30 | authenticators: map[string]authn.Authenticator{}, 31 | } 32 | return w 33 | } 34 | 35 | func (doorman *LadonDoorman) ConfigSources() []string { 36 | var l []string 37 | for _, c := range doorman.services { 38 | l = append(l, c.Source) 39 | } 40 | return l 41 | } 42 | 43 | // SetAuthenticator allows to manually set an authenticator instance associated to 44 | // a domain. 45 | func (doorman *LadonDoorman) SetAuthenticator(service string, a authn.Authenticator) { 46 | doorman.authenticators[service] = a 47 | } 48 | 49 | func (doorman *LadonDoorman) auditLogger() *auditLogger { 50 | if doorman._auditLogger == nil { 51 | doorman._auditLogger = newAuditLogger() 52 | } 53 | return doorman._auditLogger 54 | } 55 | 56 | // LoadPolicies instantiates Ladon objects from doorman's. 57 | func (doorman *LadonDoorman) LoadPolicies(configs ServicesConfig) error { 58 | // First, load each configuration file. 59 | newLadons := map[string]*ladon.Ladon{} 60 | newAuthenticators := map[string]authn.Authenticator{} 61 | newConfigs := map[string]ServiceConfig{} 62 | 63 | for _, config := range configs { 64 | _, exists := newConfigs[config.Service] 65 | if exists { 66 | return fmt.Errorf("duplicated service %q (source %q)", config.Service, config.Source) 67 | } 68 | 69 | if config.IdentityProvider != "" { 70 | log.Infof("Authentication enabled for %q using %q", config.Service, config.IdentityProvider) 71 | v, err := authn.NewAuthenticator(config.IdentityProvider) 72 | if err != nil { 73 | return err 74 | } 75 | newAuthenticators[config.Service] = v 76 | } else { 77 | log.Warningf("No authentication enabled for %q.", config.Service) 78 | } 79 | 80 | newLadons[config.Service] = &ladon.Ladon{ 81 | Manager: manager.NewMemoryManager(), 82 | AuditLogger: doorman.auditLogger(), 83 | } 84 | for _, pol := range config.Policies { 85 | log.Debugf("Load policy %q: %s", pol.ID, pol.Description) 86 | 87 | var conditions = ladon.Conditions{} 88 | for field, cond := range pol.Conditions { 89 | factory, found := ladon.ConditionFactories[cond.Type] 90 | if !found { 91 | return fmt.Errorf("unknown condition type %s", cond.Type) 92 | } 93 | c := factory() 94 | if len(cond.Options) > 0 { 95 | // Leverage Ladon JSON unmarshall code to instantiate conditions. 96 | str, _ := json.Marshal(cond.Options) 97 | if err := json.Unmarshal(str, c); err != nil { 98 | return err 99 | } 100 | } 101 | conditions.AddCondition(field, c) 102 | } 103 | 104 | policy := &ladon.DefaultPolicy{ 105 | ID: pol.ID, 106 | Description: pol.Description, 107 | Subjects: pol.Principals, 108 | Effect: pol.Effect, 109 | Resources: pol.Resources, 110 | Actions: pol.Actions, 111 | Conditions: conditions, 112 | } 113 | err := newLadons[config.Service].Manager.Create(policy) 114 | if err != nil { 115 | return err 116 | } 117 | } 118 | newConfigs[config.Service] = config 119 | } 120 | // Only if everything went well, replace existing services with new ones. 121 | doorman.services = newConfigs 122 | doorman.ladons = newLadons 123 | doorman.authenticators = newAuthenticators 124 | return nil 125 | } 126 | 127 | // Authenticator returns the authenticator for the specified service or nil. 128 | func (doorman *LadonDoorman) Authenticator(service string) (authn.Authenticator, error) { 129 | v, ok := doorman.authenticators[service] 130 | if !ok { 131 | return nil, fmt.Errorf("unknown service %q", service) 132 | } 133 | return v, nil 134 | } 135 | 136 | // IsAllowed is responsible for deciding if subject can perform action on a resource with a context. 137 | func (doorman *LadonDoorman) IsAllowed(service string, request *Request) bool { 138 | // Instantiate objects from the ladon API. 139 | context := ladon.Context{} 140 | for key, value := range request.Context { 141 | context[key] = value 142 | } 143 | 144 | r := &ladon.Request{ 145 | Resource: request.Resource, 146 | Action: request.Action, 147 | Context: context, 148 | } 149 | 150 | l, ok := doorman.ladons[service] 151 | if !ok { 152 | // Explicitly log denied request using audit logger. 153 | doorman.auditLogger().logRequest(false, r, ladon.Policies{}) 154 | return false 155 | } 156 | 157 | // For each principal, use it as the subject and query ladon backend. 158 | for _, principal := range request.Principals { 159 | r.Subject = principal 160 | if err := l.IsAllowed(r); err == nil { 161 | return true 162 | } 163 | } 164 | return false 165 | } 166 | 167 | // ExpandPrincipals will match the tags defined in the configuration for this service 168 | // against each of the specified principals. 169 | func (doorman *LadonDoorman) ExpandPrincipals(service string, principals Principals) Principals { 170 | c, ok := doorman.services[service] 171 | if !ok { 172 | return principals 173 | } 174 | 175 | return append(principals, c.GetTags(principals)...) 176 | } 177 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Doorman documentation build configuration file, created by 4 | # sphinx-quickstart on Tue Jan 30 15:41:49 2018. 5 | # 6 | # This file is execfile()d with the current directory set to its 7 | # containing dir. 8 | # 9 | # Note that not all possible configuration values are present in this 10 | # autogenerated file. 11 | # 12 | # All configuration values have a default; values that are commented out 13 | # serve to show the default. 14 | 15 | # If extensions (or modules to document with autodoc) are in another directory, 16 | # add these directories to sys.path here. If the directory is relative to the 17 | # documentation root, use os.path.abspath to make it absolute, like shown here. 18 | # 19 | # import os 20 | # import sys 21 | # sys.path.insert(0, os.path.abspath('.')) 22 | 23 | # -- Custom 24 | 25 | from recommonmark.parser import CommonMarkParser 26 | 27 | source_parsers = { 28 | '.md': CommonMarkParser, 29 | } 30 | 31 | # -- General configuration ------------------------------------------------ 32 | 33 | # If your documentation needs a minimal Sphinx version, state it here. 34 | # 35 | # needs_sphinx = '1.0' 36 | 37 | # Add any Sphinx extension module names here, as strings. They can be 38 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 39 | # ones. 40 | extensions = [ 41 | 'sphinxcontrib.openapi', 42 | ] 43 | 44 | # Add any paths that contain templates here, relative to this directory. 45 | templates_path = ['_templates'] 46 | 47 | # The suffix(es) of source filenames. 48 | # You can specify multiple suffix as a list of string: 49 | # 50 | source_suffix = ['.rst', '.md'] 51 | 52 | # The master toctree document. 53 | master_doc = 'index' 54 | 55 | # General information about the project. 56 | project = u'Doorman' 57 | copyright = u'2018, Mozilla' 58 | author = u'Mozilla' 59 | 60 | # The version info for the project you're documenting, acts as replacement for 61 | # |version| and |release|, also used in various other places throughout the 62 | # built documents. 63 | # 64 | # The short X.Y version. 65 | version = u'1.0' 66 | # The full version, including alpha/beta/rc tags. 67 | release = u'1.0.0' 68 | 69 | # The language for content autogenerated by Sphinx. Refer to documentation 70 | # for a list of supported languages. 71 | # 72 | # This is also used if you do content translation via gettext catalogs. 73 | # Usually you set "language" from the command line for these cases. 74 | language = None 75 | 76 | # List of patterns, relative to source directory, that match files and 77 | # directories to ignore when looking for source files. 78 | # This patterns also effect to html_static_path and html_extra_path 79 | exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] 80 | 81 | # The name of the Pygments (syntax highlighting) style to use. 82 | pygments_style = 'sphinx' 83 | 84 | # If true, `todo` and `todoList` produce output, else they produce nothing. 85 | todo_include_todos = False 86 | 87 | 88 | # -- Options for HTML output ---------------------------------------------- 89 | 90 | # The theme to use for HTML and HTML Help pages. See the documentation for 91 | # a list of builtin themes. 92 | # 93 | html_theme = 'alabaster' 94 | 95 | # Theme options are theme-specific and customize the look and feel of a theme 96 | # further. For a list of options available for each theme, see the 97 | # documentation. 98 | # 99 | # html_theme_options = {} 100 | 101 | # Add any paths that contain custom static files (such as style sheets) here, 102 | # relative to this directory. They are copied after the builtin static files, 103 | # so a file named "default.css" will overwrite the builtin "default.css". 104 | html_static_path = ['_static'] 105 | 106 | # Custom sidebar templates, must be a dictionary that maps document names 107 | # to template names. 108 | # 109 | # This is required for the alabaster theme 110 | # refs: http://alabaster.readthedocs.io/en/latest/installation.html#sidebars 111 | html_sidebars = { 112 | '**': [ 113 | 'relations.html', # needs 'show_related': True theme option to display 114 | 'searchbox.html', 115 | ] 116 | } 117 | 118 | 119 | # -- Options for HTMLHelp output ------------------------------------------ 120 | 121 | # Output file base name for HTML help builder. 122 | htmlhelp_basename = 'Doormandoc' 123 | 124 | 125 | # -- Options for LaTeX output --------------------------------------------- 126 | 127 | latex_elements = { 128 | # The paper size ('letterpaper' or 'a4paper'). 129 | # 130 | # 'papersize': 'letterpaper', 131 | 132 | # The font size ('10pt', '11pt' or '12pt'). 133 | # 134 | # 'pointsize': '10pt', 135 | 136 | # Additional stuff for the LaTeX preamble. 137 | # 138 | # 'preamble': '', 139 | 140 | # Latex figure (float) alignment 141 | # 142 | # 'figure_align': 'htbp', 143 | } 144 | 145 | # Grouping the document tree into LaTeX files. List of tuples 146 | # (source start file, target name, title, 147 | # author, documentclass [howto, manual, or own class]). 148 | latex_documents = [ 149 | (master_doc, 'Doorman.tex', u'Doorman Documentation', 150 | u'Mozilla', 'manual'), 151 | ] 152 | 153 | 154 | # -- Options for manual page output --------------------------------------- 155 | 156 | # One entry per manual page. List of tuples 157 | # (source start file, name, description, authors, manual section). 158 | man_pages = [ 159 | (master_doc, 'doorman', u'Doorman Documentation', 160 | [author], 1) 161 | ] 162 | 163 | 164 | # -- Options for Texinfo output ------------------------------------------- 165 | 166 | # Grouping the document tree into Texinfo files. List of tuples 167 | # (source start file, target name, title, author, 168 | # dir menu entry, description, category) 169 | texinfo_documents = [ 170 | (master_doc, 'Doorman', u'Doorman Documentation', 171 | author, 'Doorman', 'One line description of project.', 172 | 'Miscellaneous'), 173 | ] 174 | 175 | 176 | 177 | -------------------------------------------------------------------------------- /examples/README.md: -------------------------------------------------------------------------------- 1 | # Integration Examples 2 | 3 | - [Python / Flask](python/): A Web UI interacts with Auth0 and a Flask API 4 | 5 | # Configuration Examples 6 | 7 | ## Roles from service 8 | 9 | A role is a *principal* that usually depends on the relation between a user and a resource. For example, `alice` is the `author` of `articles/42`. 10 | 11 | Since it's not the responsability of the *Identity Provider* nor *Doorman* to manage this relation, the service sends the roles of the current user in the authorization request `context`. 12 | 13 | For instance, for a service where objects and users are stored in its database (e.g Django), the service will: 14 | 15 | 1. verify and read the JWT payload to get the authenticated userid 16 | 1. fetch the resource attributes and lookup the user in the database 17 | 1. determine the user roles with regards to this object (eg. `author`, `collaborator`, or global `superuser`...) 18 | 1. send an authorization request to Doorman with roles in the context field 19 | 20 | And Doorman will match rules and determine if the user is allowed to perform the specified action on the specified resource 21 | 22 | In the example below, we rely on the groups of given by the *Identity Provider* to allow creating articles, and on the roles provided by the service to determine who can edit them: 23 | 24 | ```yaml 25 | service: gurghruin435u85O539g7cKvWBOI 26 | identityProvider: https://auth.mozilla.auth0.com/ 27 | policies: 28 | - 29 | id: create-articles 30 | description: Members of the moco group can create articles 31 | principals: 32 | - group:moco 33 | actions: 34 | - create 35 | resources: 36 | - article 37 | effect: allow 38 | - 39 | id: edit-articles 40 | description: Authors and collaborators can edit articles 41 | principals: 42 | - role:author 43 | - role:collaborator 44 | actions: 45 | - read 46 | - update 47 | resources: 48 | - article 49 | effect: allow 50 | - 51 | id: super-users 52 | description: Superusers can do everything 53 | principals: 54 | - role:superuser 55 | actions: 56 | - <.*> 57 | resources: 58 | - <.*> 59 | effect: allow 60 | ``` 61 | 62 | An authorization request sent from the service can look like this (here, the user is author of the article): 63 | 64 | ``` 65 | curl -s -X POST http://localhost:8080/allowed \ 66 | -H "Authorization: Bearer $ACCESS_TOKEN" \ 67 | -H "Content-Type: application/json" \ 68 | -H "Origin: gurghruin435u85O539g7cKvWBOI" 69 | -d @- << EOF 70 | 71 | { 72 | "action": "update", 73 | "resource": "article", 74 | "context": { 75 | "roles": ["author"] 76 | } 77 | } 78 | EOF 79 | ``` 80 | 81 | Which in this case returns: 82 | 83 | ```json 84 | { 85 | "allowed": true, 86 | "principals": [ 87 | "userid:mleplatre", 88 | "role:author", 89 | "group:moco", 90 | "group:irccloud", 91 | "group:vpn", 92 | "group:cloudservices" 93 | ] 94 | } 95 | ``` 96 | 97 | - *TODO: add a small Django demo* 98 | 99 | 100 | ## Doorman tags 101 | 102 | For example, you want to use Doorman to maintain a carefully curated list of people who should become "superusers" when they log in to a certain service. This means the service doesn't have to rely on an *Identity Provider* nor build the functionality to promote and demote superusers. 103 | 104 | To do that, we define a tag `superuser` along with the intended principals in the service configuration. And then in the policies rules, we refer to this tag as the `tag:superuser` principal. 105 | 106 | ```yaml 107 | service: https://api.service.org 108 | identityProvider: # disabled 109 | tags: 110 | superuser: 111 | - userid:maria 112 | - group:admins 113 | policies: 114 | - 115 | id: super-users 116 | description: Superusers can do everything 117 | principals: 118 | - tag:superuser 119 | actions: 120 | - <.*> 121 | resources: 122 | - <.*> 123 | effect: allow 124 | ``` 125 | 126 | In the example above, the userid `maria` or the members of the `admins` group are allowed to perform any action on any resource on the `https://api.service.org` service. 127 | 128 | Since in this case we didn't enable authentication, an authorization request specifies the `principals` and looks like this: 129 | 130 | ``` 131 | curl -s -X POST http://localhost:8080/allowed \ 132 | -H "Content-Type: application/json" \ 133 | -H "Origin: https://api.service.org" 134 | -d @- << EOF 135 | 136 | { 137 | "principals": ["userid:maria", "group:employees", "group:france"] 138 | "action": "disable", 139 | "resource": "notifications" 140 | } 141 | EOF 142 | ``` 143 | 144 | Which in this case returns: 145 | 146 | ```json 147 | { 148 | "allowed": true, 149 | "principals": [ 150 | "userid:maria", 151 | "tag:superuser", 152 | "group:employees", 153 | "group:france" 154 | ] 155 | } 156 | ``` 157 | 158 | ## Mozilla specific 159 | 160 | *Doorman* is not tied to Mozilla in any other way that its maintainers work at Mozilla. The solutions examples presented in this section are Mozilla specific, but only because they're common among early users of *Doorman*. 161 | 162 | ### Staff only 163 | 164 | Mozilla integrated Auth0 and its HR system so that when users are members of staff, they are given the special group `hris_is_staff`. 165 | 166 | Restricting access on a service to the members of staff at Mozilla is thus as simple as: 167 | 168 | ```yaml 169 | service: SLocf7Sa1ibd5GN 170 | ìdentityProvider: https://auth.mozilla.auth0.com 171 | policies: 172 | - 173 | id: staff-only 174 | description: Staff only 175 | principals: 176 | - group:hris_is_staff 177 | actions: 178 | - <.*> 179 | resources: 180 | - <.*> 181 | effect: allow 182 | ``` 183 | 184 | ### Employees and contractors 185 | 186 | Following the same approach as staff, employees and contractors can be differenciated within staff via specific HRIS groups: `hris_workertype_employee` and `hris_workertype_contractor` respectively. 187 | -------------------------------------------------------------------------------- /docs/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 19 | 21 | 43 | 45 | 46 | 48 | image/svg+xml 49 | 51 | 52 | 53 | 54 | 55 | 60 | 66 | 72 | 78 | 84 | 85 | 86 | -------------------------------------------------------------------------------- /api/allowed_test.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "io" 7 | "net/http" 8 | "net/http/httptest" 9 | "testing" 10 | 11 | "github.com/gin-gonic/gin" 12 | "github.com/mozilla/doorman/config" 13 | "github.com/mozilla/doorman/doorman" 14 | "github.com/stretchr/testify/assert" 15 | "github.com/stretchr/testify/require" 16 | ) 17 | 18 | type AllowedResponse struct { 19 | Allowed bool 20 | Principals doorman.Principals 21 | } 22 | 23 | type ErrorResponse struct { 24 | Message string 25 | } 26 | 27 | func performAllowed(t *testing.T, r *gin.Engine, body io.Reader, expected int, response interface{}) { 28 | w := performRequest(r, "POST", "/allowed", body) 29 | require.Equal(t, expected, w.Code) 30 | err := json.Unmarshal(w.Body.Bytes(), &response) 31 | require.Nil(t, err) 32 | } 33 | 34 | func TestAllowedGet(t *testing.T) { 35 | r := gin.New() 36 | d := doorman.NewDefaultLadon() 37 | SetupRoutes(r, d) 38 | 39 | w := performRequest(r, "GET", "/allowed", nil) 40 | assert.Equal(t, w.Code, http.StatusNotFound) 41 | } 42 | 43 | func TestAllowedVerifiesAuthentication(t *testing.T) { 44 | d := doorman.NewDefaultLadon() 45 | // Will initialize an authenticator (ie. download public keys) 46 | d.LoadPolicies(doorman.ServicesConfig{ 47 | doorman.ServiceConfig{ 48 | Service: "https://sample.yaml", 49 | IdentityProvider: "https://auth.mozilla.auth0.com/", 50 | Policies: doorman.Policies{ 51 | doorman.Policy{ 52 | Actions: []string{"update"}, 53 | }, 54 | }, 55 | }, 56 | }) 57 | 58 | r := gin.New() 59 | SetupRoutes(r, d) 60 | 61 | authzRequest := doorman.Request{} 62 | token, _ := json.Marshal(authzRequest) 63 | body := bytes.NewBuffer(token) 64 | var response ErrorResponse 65 | // Missing Authorization header. 66 | performAllowed(t, r, body, http.StatusUnauthorized, &response) 67 | assert.Equal(t, "token not found", response.Message) 68 | } 69 | 70 | func TestAllowedHandlerBadRequest(t *testing.T) { 71 | var errResp ErrorResponse 72 | 73 | // Empty body 74 | w := httptest.NewRecorder() 75 | c, _ := gin.CreateTestContext(w) 76 | 77 | c.Request, _ = http.NewRequest("POST", "/allowed", nil) 78 | allowedHandler(c) 79 | assert.Equal(t, http.StatusBadRequest, w.Code) 80 | json.Unmarshal(w.Body.Bytes(), &errResp) 81 | assert.Equal(t, errResp.Message, "Missing body") 82 | 83 | // Invalid JSON 84 | w = httptest.NewRecorder() 85 | c, _ = gin.CreateTestContext(w) 86 | 87 | body := bytes.NewBuffer([]byte("{\"random\\;mess\"}")) 88 | c.Request, _ = http.NewRequest("POST", "/allowed", body) 89 | allowedHandler(c) 90 | assert.Equal(t, http.StatusBadRequest, w.Code) 91 | json.Unmarshal(w.Body.Bytes(), &errResp) 92 | assert.Contains(t, errResp.Message, "invalid character ';'") 93 | 94 | // Missing principals when AuthnMiddleware not enabled. 95 | w = httptest.NewRecorder() 96 | c, _ = gin.CreateTestContext(w) 97 | 98 | body = bytes.NewBuffer([]byte("{\"action\":\"update\"}")) 99 | c.Request, _ = http.NewRequest("POST", "/allowed", body) 100 | allowedHandler(c) 101 | assert.Equal(t, http.StatusBadRequest, w.Code) 102 | json.Unmarshal(w.Body.Bytes(), &errResp) 103 | assert.Contains(t, errResp.Message, "missing principals") 104 | 105 | d := doorman.NewDefaultLadon() 106 | 107 | // Posted principals with AuthnMiddleware enabled. 108 | w = httptest.NewRecorder() 109 | c, _ = gin.CreateTestContext(w) 110 | c.Set(DoormanContextKey, d) 111 | c.Set(PrincipalsContextKey, doorman.Principals{"userid:maria"}) // Simulate authn middleware. 112 | authzRequest := doorman.Request{ 113 | Principals: doorman.Principals{"userid:superuser"}, 114 | } 115 | post, _ := json.Marshal(authzRequest) 116 | body = bytes.NewBuffer(post) 117 | c.Request, _ = http.NewRequest("POST", "/allowed", body) 118 | allowedHandler(c) 119 | assert.Equal(t, http.StatusBadRequest, w.Code) 120 | json.Unmarshal(w.Body.Bytes(), &errResp) 121 | assert.Contains(t, errResp.Message, "cannot submit principals with authentication enabled") 122 | } 123 | 124 | func TestAllowedHandler(t *testing.T) { 125 | var resp AllowedResponse 126 | 127 | w := httptest.NewRecorder() 128 | c, _ := gin.CreateTestContext(w) 129 | 130 | configs, err := config.Load([]string{"../sample.yaml"}) 131 | require.Nil(t, err) 132 | 133 | d := doorman.NewDefaultLadon() 134 | err = d.LoadPolicies(configs) 135 | require.Nil(t, err) 136 | c.Set(DoormanContextKey, d) 137 | 138 | // Using principals from context (AuthnMiddleware) 139 | c.Set(PrincipalsContextKey, doorman.Principals{"userid:maria"}) 140 | 141 | authzRequest := doorman.Request{ 142 | Action: "update", 143 | } 144 | post, _ := json.Marshal(authzRequest) 145 | body := bytes.NewBuffer(post) 146 | c.Request, _ = http.NewRequest("POST", "/allowed", body) 147 | c.Request.Header.Set("Origin", "https://sample.yaml") 148 | 149 | allowedHandler(c) 150 | 151 | assert.Equal(t, http.StatusOK, w.Code) 152 | json.Unmarshal(w.Body.Bytes(), &resp) 153 | assert.True(t, resp.Allowed) 154 | assert.Equal(t, doorman.Principals{"userid:maria", "tag:admins"}, resp.Principals) 155 | } 156 | 157 | func TestAllowedHandlerRoles(t *testing.T) { 158 | var resp AllowedResponse 159 | 160 | w := httptest.NewRecorder() 161 | c, _ := gin.CreateTestContext(w) 162 | 163 | configs, err := config.Load([]string{"../sample.yaml"}) 164 | require.Nil(t, err) 165 | 166 | println(len(configs)) 167 | d := doorman.NewDefaultLadon() 168 | err = d.LoadPolicies(configs) 169 | require.Nil(t, err) 170 | c.Set(DoormanContextKey, d) 171 | 172 | // Expand principals from context roles 173 | authzRequest := doorman.Request{ 174 | Principals: doorman.Principals{"userid:bob"}, 175 | Action: "update", 176 | Resource: "pto", 177 | Context: doorman.Context{ 178 | "roles": []string{"editor"}, 179 | }, 180 | } 181 | post, _ := json.Marshal(authzRequest) 182 | body := bytes.NewBuffer(post) 183 | c.Request, _ = http.NewRequest("POST", "/allowed", body) 184 | 185 | allowedHandler(c) 186 | 187 | json.Unmarshal(w.Body.Bytes(), &resp) 188 | assert.Equal(t, doorman.Principals{"userid:bob", "role:editor"}, resp.Principals) 189 | } 190 | -------------------------------------------------------------------------------- /examples/python/ui/main.js: -------------------------------------------------------------------------------- 1 | const SERVICE_URL = 'http://localhost:8000' 2 | 3 | const AUTH0_CLIENT_ID = 'SLocf7Sa1ibd5GNJMMqO539g7cKvWBOI'; 4 | const AUTH0_DOMAIN = 'auth.mozilla.auth0.com'; 5 | const AUTH0_CALLBACK_URL = window.location.href; 6 | const SCOPES = 'openid profile email'; 7 | 8 | 9 | document.addEventListener('DOMContentLoaded', main); 10 | 11 | function main() { 12 | const webAuth0 = new auth0.WebAuth({ 13 | domain: AUTH0_DOMAIN, 14 | clientID: AUTH0_CLIENT_ID, 15 | redirectUri: AUTH0_CALLBACK_URL, 16 | responseType: 'token id_token', 17 | scope: SCOPES 18 | }); 19 | 20 | // Start authentication process on Login button 21 | const loginBtn = document.getElementById('login'); 22 | loginBtn.addEventListener('click', () => { 23 | webAuth0.authorize(); 24 | }); 25 | // Logout button. 26 | const logoutBtn = document.getElementById('logout'); 27 | logoutBtn.addEventListener('click', logout); 28 | 29 | handleAuthentication(webAuth0) 30 | } 31 | 32 | class APIClient { 33 | constructor(auth) { 34 | const headers = { 35 | 'Authorization': `${auth.tokenType} ${auth.accessToken}`, 36 | }; 37 | this.options = {headers}; 38 | } 39 | 40 | async hello() { 41 | const resp = await fetch(`${SERVICE_URL}/`, this.options); 42 | return await resp.json(); 43 | } 44 | 45 | async list() { 46 | const resp = await fetch(`${SERVICE_URL}/records`, this.options); 47 | return await resp.json(); 48 | } 49 | 50 | async save(name, body) { 51 | const resp = await fetch(`${SERVICE_URL}/records/${name}`, 52 | {method: 'PUT', body, ...this.options}); 53 | return await resp.json(); 54 | } 55 | } 56 | 57 | function handleAuthentication(webAuth0) { 58 | let authenticated = false; 59 | 60 | webAuth0.parseHash((err, authResult) => { 61 | if (authResult && authResult.accessToken && authResult.idToken) { 62 | // Token was passed in location hash by authentication portal. 63 | authenticated = true; 64 | window.location.hash = ''; 65 | setSession(authResult); 66 | } else if (err) { 67 | // Authentication returned an error. 68 | showError(err.errorDescription); 69 | } else { 70 | // Look into session storage for session. 71 | const expiresAt = JSON.parse(sessionStorage.getItem('expires_at')); 72 | // Check whether the current time is past the access token's expiry time 73 | if (new Date().getTime() < expiresAt) { 74 | authenticated = true; 75 | authResult = JSON.parse(sessionStorage.getItem('session')); 76 | } 77 | } 78 | 79 | // Show/hide menus. 80 | displayButtons(authenticated) 81 | 82 | // Interact with API if authenticated. 83 | if (authenticated) { 84 | console.log('AuthResult', authResult); 85 | showTokenPayload(authResult) 86 | 87 | const apiClient = new APIClient(authResult); 88 | 89 | initRecordForm(apiClient) 90 | 91 | Promise.all([ 92 | fetchUserInfo(webAuth0, authResult), 93 | showAPIHello(apiClient), 94 | showAPIRecords(apiClient), 95 | ]) 96 | .catch(showError); 97 | } 98 | }); 99 | } 100 | 101 | function showError(err) { 102 | console.error(err); 103 | const errorDiv = document.getElementById('error'); 104 | errorDiv.style.display = 'block'; 105 | errorDiv.innerText = err; 106 | } 107 | 108 | function displayButtons(authenticated) { 109 | if (authenticated) { 110 | document.getElementById('login').setAttribute('disabled', 'disabled'); 111 | document.getElementById('logout').removeAttribute('disabled'); 112 | document.getElementById('view').style.display = 'block'; 113 | } else { 114 | document.getElementById('login').removeAttribute('disabled'); 115 | document.getElementById('logout').setAttribute('disabled', 'disabled'); 116 | document.getElementById('view').style.display = 'none'; 117 | } 118 | } 119 | 120 | function setSession(authResult) { 121 | // Set the time that the access token will expire at 122 | const expiresAt = JSON.stringify( 123 | authResult.expiresIn * 1000 + new Date().getTime() 124 | ); 125 | sessionStorage.setItem('session', JSON.stringify(authResult)); 126 | sessionStorage.setItem('expires_at', expiresAt); 127 | } 128 | 129 | function logout() { 130 | // Remove tokens and expiry time from sessionStorage 131 | sessionStorage.removeItem('session'); 132 | sessionStorage.removeItem('expires_at'); 133 | displayButtons(false); 134 | } 135 | 136 | async function fetchUserInfo(webAuth0, auth) { 137 | webAuth0.client.userInfo(auth.accessToken, (err, profile) => { 138 | if (err) { 139 | throw err; 140 | } 141 | document.getElementById('profile-nickname').innerText = profile.nickname; 142 | document.getElementById('profile-picture').setAttribute('src', profile.picture); 143 | document.getElementById('profile-details').innerText = JSON.stringify(profile, null, 2); 144 | }); 145 | } 146 | 147 | function showTokenPayload(auth) { 148 | const tokenPayloadDiv = document.getElementById('token-payload'); 149 | tokenPayloadDiv.innerText = JSON.stringify(auth.idTokenPayload, null, 2); 150 | } 151 | 152 | async function showAPIHello(apiClient) { 153 | const data = await apiClient.hello(); 154 | 155 | const apiHelloDiv = document.getElementById('api-hello'); 156 | apiHelloDiv.innerText = JSON.stringify(data, null, 2); 157 | } 158 | 159 | function initRecordForm(apiClient) { 160 | const newRecordForm = document.getElementById('api-record-form'); 161 | // Submit data. 162 | newRecordForm.addEventListener('submit', async (e) => { 163 | e.preventDefault(); 164 | const formData = new FormData(newRecordForm); 165 | await apiClient.save(formData.get('name'), formData.get('data')); 166 | // Empty form once submitted. 167 | newRecordForm.reset() 168 | // Refresh list. 169 | await showAPIRecords(apiClient); 170 | }); 171 | } 172 | 173 | async function showAPIRecords(apiClient) { 174 | const apiRecordsDiv = document.getElementById('api-records'); 175 | apiRecordsDiv.innerHTML = ''; 176 | 177 | const data = await apiClient.list(); 178 | if (data.length == 0) { 179 | apiRecordsDiv.innerText = 'No records'; 180 | return 181 | } 182 | for (const {name, body} of data) { 183 | const _name = document.createElement('h2'); 184 | _name.innerText = name; 185 | const _body = document.createElement('p'); 186 | _body.className = 'pre'; 187 | _body.innerText = body; 188 | apiRecordsDiv.appendChild(_name); 189 | apiRecordsDiv.appendChild(_body); 190 | } 191 | } 192 | -------------------------------------------------------------------------------- /api/openapi.yaml: -------------------------------------------------------------------------------- 1 | openapi: 2.0.0 2 | info: 3 | title: "Mozilla Doorman" 4 | description: | 5 | *Doorman* is an **authorization micro-service** that allows to checks if an arbitrary subject is allowed to perform an action on a resource, based on a set of rules (policies). 6 | version: "0.1" 7 | contact: 8 | url: "irc://irc.mozilla.org:6696/#product-delivery" 9 | license: 10 | name: "Mozilla Public License 2.0" 11 | url: "http://www.apache.org/licenses/LICENSE-2.0.html" 12 | 13 | tags: 14 | - name: Doorman 15 | description: Main API endpoints. 16 | - name: Utilities 17 | description: Operational and metadata endpoints. 18 | 19 | paths: 20 | 21 | /allowed: 22 | post: 23 | summary: Check authorization request 24 | description: | 25 | Are those ``principals`` allowed to perform this ``action`` on this ``resource`` in this ``context``? 26 | 27 | With authentication enabled, the principals are either read from the Identity Provider user info endpoint or directly from the JSON Web Token payload 28 | if an ID token is provided. 29 | 30 | operationId: "allowed" 31 | consumes: 32 | - application/json 33 | produces: 34 | - "application/json" 35 | parameters: 36 | - in: header 37 | name: Origin 38 | type: string 39 | description: | 40 | The service identifier (eg. ``https://api.service.org``). It must match one of the known service from the policies files. 41 | 42 | - in: header 43 | name: Authorization 44 | type: string 45 | description: | 46 | With OpenID enabled, a valid Access token (or JSON Web ID Token) must be provided in the ``Authorization`` request header. 47 | (eg. `Bearer eyJ0eXAiOiJKV1QiLCJhbG...9USXpOalEzUXpV`) 48 | 49 | - in: body 50 | description: | 51 | Authorization request as JSON. 52 | 53 | Note that **every field is optional**. 54 | 55 | required: true 56 | schema: 57 | type: object 58 | properties: 59 | principals: 60 | description: | 61 | **Only without authentication** 62 | 63 | Arbitrary list of strings (eg. ``userid:alice``, ``group:editors``). 64 | 65 | type: array 66 | items: 67 | type: string 68 | action: 69 | description: Any domain specific action (eg. ``read``, ``delete``, ``signoff``) 70 | type: string 71 | resource: 72 | description: Any resource (eg. ``blocklist``, ``rules-<.*>``) 73 | type: string 74 | context: 75 | description: | 76 | The context can contain any extra information to be matched in policies conditions. 77 | The context field ``remoteIP`` will be forced by the server. 78 | The values provided in the ``roles`` context field will expand the principals with extra ``role:{}`` values. 79 | 80 | type: object 81 | properties: 82 | roles: 83 | type: array 84 | items: 85 | type: string 86 | example: 87 | principals: ["userid:ldap|ada", "email:ada@lau.co"] 88 | action: create 89 | resource: comment 90 | context: 91 | env: 92 | - stage 93 | roles: 94 | - changer 95 | responses: 96 | "400": 97 | description: "Missing headers or invalid posted data." 98 | schema: 99 | type: object 100 | properties: 101 | message: 102 | type: string 103 | example: 104 | message: Missing ``Origin`` request header 105 | "401": 106 | description: "OpenID token is invalid." 107 | "200": 108 | description: "Return whether it is allowed or not." 109 | schema: 110 | type: object 111 | properties: 112 | allowed: 113 | type: boolean 114 | principals: 115 | type: array 116 | items: 117 | type: string 118 | example: 119 | allowed: true 120 | principals: ["userid:ldap|ada", "email:ada@lau.co", "tag:mayor", "role:changer"] 121 | tags: 122 | - Doorman 123 | 124 | /__reload__: 125 | post: 126 | summary: "Reload the policies" 127 | description: | 128 | Reload the policies (synchronously). This endpoint is meant to be used as a Web hook when policies files were changed upstream. 129 | 130 | > It would be wise to limit the access to this endpoint (e.g. by IP on reverse proxy) 131 | 132 | operationId: "reload" 133 | produces: 134 | - "application/json" 135 | responses: 136 | "200": 137 | description: "Reloaded successfully." 138 | schema: 139 | type: object 140 | properties: 141 | success: 142 | type: boolean 143 | example: 144 | success: true 145 | 146 | "500": 147 | description: "Reload failed." 148 | schema: 149 | type: object 150 | properties: 151 | message: 152 | type: string 153 | example: 154 | success: false 155 | message: could not parse YAML in "https://github.com/ops/conf/policies.yaml" 156 | tags: 157 | - Doorman 158 | 159 | /__heartbeat__: 160 | get: 161 | summary: "Is the server working properly? What is failing?" 162 | operationId: "heartbeat" 163 | produces: 164 | - "application/json" 165 | responses: 166 | "200": 167 | description: "Server working properly" 168 | schema: 169 | type: "object" 170 | example: 171 | "503": 172 | description: "One or more subsystems failing." 173 | schema: 174 | type: "object" 175 | example: 176 | tags: 177 | - Utilities 178 | 179 | /__lbheartbeat__: 180 | get: 181 | summary: "Is the server reachable?" 182 | operationId: "lbheartbeat" 183 | produces: 184 | - "application/json" 185 | responses: 186 | "200": 187 | description: "Server reachable" 188 | schema: 189 | type: "object" 190 | properties: 191 | ok: 192 | type: boolean 193 | example: 194 | ok: true 195 | tags: 196 | - Utilities 197 | 198 | /__version__: 199 | get: 200 | summary: "Running instance version information" 201 | operationId: "version" 202 | produces: 203 | - "application/json" 204 | responses: 205 | "200": 206 | description: "Return the running instance version information" 207 | schema: 208 | type: "object" 209 | properties: 210 | source: 211 | type: string 212 | version: 213 | type: string 214 | commit: 215 | type: string 216 | build: 217 | type: string 218 | example: 219 | source: https://github.com/mozilla/doorman 220 | version: "1.0" 221 | commit: 490ed70efff482d17a 222 | build: "20171102" 223 | tags: 224 | - Utilities 225 | 226 | /__api__: 227 | get: 228 | summary: "Open API Specification documentation." 229 | operationId: "doc" 230 | produces: 231 | - "application/json" 232 | responses: 233 | "200": 234 | description: "Return the Open Api Specification." 235 | schema: 236 | type: "object" 237 | tags: 238 | - Utilities 239 | 240 | /contribute.json: 241 | get: 242 | summary: "Open source contributing information" 243 | operationId: "contribute" 244 | produces: 245 | - "application/json" 246 | responses: 247 | "200": 248 | description: "Return open source contributing information." 249 | schema: 250 | type: "object" 251 | tags: 252 | - Utilities 253 | -------------------------------------------------------------------------------- /authn/openid.go: -------------------------------------------------------------------------------- 1 | package authn 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "io/ioutil" 7 | "net/http" 8 | "strings" 9 | "time" 10 | 11 | "github.com/allegro/bigcache" 12 | "github.com/pkg/errors" 13 | log "github.com/sirupsen/logrus" 14 | jose "gopkg.in/square/go-jose.v2" 15 | jwt "gopkg.in/square/go-jose.v2/jwt" 16 | ) 17 | 18 | // CacheTTL is the cache duration for remote info like OpenID config or keys. 19 | const CacheTTL = 1 * time.Hour 20 | 21 | // openIDConfiguration is the OpenID provider metadata about URIs, endpoints etc. 22 | type openIDConfiguration struct { 23 | JWKSUri string `json:"jwks_uri"` 24 | UserInfoEndpoint string `json:"userinfo_endpoint"` 25 | } 26 | 27 | // publicKeys are the JWT public keys 28 | type publicKeys struct { 29 | Keys []jose.JSONWebKey `json:"keys"` 30 | } 31 | 32 | type openIDAuthenticator struct { 33 | Issuer string 34 | SignatureAlgorithm jose.SignatureAlgorithm 35 | ClaimExtractor claimExtractor 36 | cache *bigcache.BigCache 37 | envTest bool 38 | } 39 | 40 | // newOpenIDAuthenticator returns a new instance of a generic JWT validator 41 | // for the specified issuer. 42 | func newOpenIDAuthenticator(issuer string) *openIDAuthenticator { 43 | cache, _ := bigcache.NewBigCache(bigcache.DefaultConfig(CacheTTL)) 44 | 45 | var extractor claimExtractor = defaultExtractor 46 | if strings.Contains(issuer, "mozilla.auth0.com") { 47 | extractor = mozillaExtractor 48 | } 49 | return &openIDAuthenticator{ 50 | Issuer: issuer, 51 | SignatureAlgorithm: jose.RS256, 52 | ClaimExtractor: extractor, 53 | cache: cache, 54 | envTest: false, 55 | } 56 | } 57 | 58 | func (v *openIDAuthenticator) config() (*openIDConfiguration, error) { 59 | cacheKey := "config:" + v.Issuer 60 | data, err := v.cache.Get(cacheKey) 61 | 62 | // Cache is empty or expired: fetch again. 63 | if err != nil { 64 | uri := strings.TrimRight(v.Issuer, "/") + "/.well-known/openid-configuration" 65 | log.Debugf("Fetch OpenID configuration from %s", uri) 66 | data, err = downloadJSON(uri, nil) 67 | if err != nil { 68 | return nil, errors.Wrap(err, "failed to fetch OpenID configuration") 69 | } 70 | v.cache.Set(cacheKey, data) 71 | } 72 | 73 | // Since cache stores bytes, we parse it again at every usage :( ? 74 | config := &openIDConfiguration{} 75 | err = json.Unmarshal(data, config) 76 | if err != nil { 77 | return nil, errors.Wrap(err, "failed to parse OpenID configuration") 78 | } 79 | if config.JWKSUri == "" { 80 | return nil, fmt.Errorf("no jwks_uri attribute in OpenID configuration") 81 | } 82 | return config, nil 83 | } 84 | 85 | func (v *openIDAuthenticator) jwks() (*publicKeys, error) { 86 | cacheKey := "jwks:" + v.Issuer 87 | data, err := v.cache.Get(cacheKey) 88 | 89 | // Cache is empty or expired: fetch again. 90 | if err != nil { 91 | config, err := v.config() 92 | if err != nil { 93 | return nil, err 94 | } 95 | uri := config.JWKSUri 96 | log.Debugf("Fetch public keys from %s", uri) 97 | data, err = downloadJSON(uri, nil) 98 | if err != nil { 99 | return nil, errors.Wrap(err, "failed to fetch JWKS") 100 | } 101 | v.cache.Set(cacheKey, data) 102 | } 103 | 104 | var jwks = &publicKeys{} 105 | err = json.Unmarshal(data, jwks) 106 | if err != nil { 107 | return nil, errors.Wrap(err, "failed to parse JWKS") 108 | } 109 | 110 | if len(jwks.Keys) < 1 { 111 | return nil, fmt.Errorf("no JWKS found") 112 | } 113 | return jwks, nil 114 | } 115 | 116 | func (v *openIDAuthenticator) ValidateRequest(r *http.Request) (*UserInfo, error) { 117 | headerValue, err := fromHeader(r) 118 | if err != nil { 119 | return nil, err 120 | } 121 | 122 | if strings.Count(headerValue, ".") == 0 { 123 | // No dots, could be an access token! Try to fetch user infos. 124 | userinfo, err := v.FetchUserInfo(headerValue) 125 | if err == nil { 126 | return userinfo, nil 127 | } 128 | } 129 | 130 | // Consider it an ID Token. It will fail if invalid. 131 | audience := r.Header.Get("Origin") 132 | userinfo, err := v.FromJWTPayload(headerValue, audience) 133 | if err != nil { 134 | return nil, err 135 | } 136 | return userinfo, nil 137 | } 138 | 139 | // FetchUserInfo fetches the user profile infos using the specified access token. 140 | // The obtained data is cached using the access token as the cache key. 141 | func (v *openIDAuthenticator) FetchUserInfo(accessToken string) (*UserInfo, error) { 142 | cacheKey := "userinfo:" + accessToken 143 | 144 | data, err := v.cache.Get(cacheKey) 145 | // Cache is empty or expired: fetch again. 146 | if err != nil { 147 | config, err := v.config() 148 | if err != nil { 149 | return nil, err 150 | } 151 | uri := config.UserInfoEndpoint 152 | data, err = downloadJSON(uri, http.Header{ 153 | "Authorization": []string{"Bearer " + accessToken}, 154 | }) 155 | if err != nil { 156 | return nil, errors.Wrap(err, fmt.Sprintf("could not fetch userinfo from %s", uri)) 157 | } 158 | v.cache.Set(cacheKey, data) 159 | } 160 | 161 | userinfo, err := v.ClaimExtractor.Extract(data) 162 | if err != nil { 163 | return nil, err 164 | } 165 | 166 | return userinfo, nil 167 | } 168 | 169 | func (v *openIDAuthenticator) FromJWTPayload(idToken string, audience string) (*UserInfo, error) { 170 | // 1. Instanciate JSON Web Token 171 | token, err := jwt.ParseSigned(idToken) 172 | if err != nil { 173 | return nil, err 174 | } 175 | 176 | // 2. Read JWT headers 177 | if len(token.Headers) < 1 { 178 | return nil, fmt.Errorf("no headers in the token") 179 | } 180 | header := token.Headers[0] 181 | if header.Algorithm != string(v.SignatureAlgorithm) { 182 | return nil, fmt.Errorf("invalid algorithm") 183 | } 184 | 185 | // 3. Get public key with specified ID 186 | keys, err := v.jwks() 187 | if err != nil { 188 | return nil, err 189 | } 190 | var key *jose.JSONWebKey 191 | for _, k := range keys.Keys { 192 | if k.KeyID == header.KeyID { 193 | key = &k 194 | break 195 | } 196 | } 197 | if key == nil { 198 | return nil, fmt.Errorf("no JWT key with id %q", header.KeyID) 199 | } 200 | 201 | // 4. Parse and verify signature. 202 | jwtClaims := jwt.Claims{} 203 | err = token.Claims(key, &jwtClaims) 204 | if err != nil { 205 | return nil, errors.Wrap(err, "failed to read JWT payload") 206 | } 207 | 208 | // 5. Validate issuer, audience, claims and expiration. 209 | expected := jwt.Expected{ 210 | Issuer: v.Issuer, 211 | Audience: jwt.Audience{audience}, 212 | } 213 | expected = expected.WithTime(time.Now()) 214 | err = jwtClaims.Validate(expected) 215 | if err != nil && !v.envTest { // flag for unit tests. 216 | return nil, errors.Wrap(err, "invalid JWT claims") 217 | } 218 | 219 | // 6. Decrypt/verify JWT payload to basic JSON. 220 | var payload map[string]interface{} 221 | err = token.Claims(key, &payload) 222 | if err != nil { 223 | return nil, errors.Wrap(err, "failed to decrypt/verify JWT claims") 224 | } 225 | data, err := json.Marshal(payload) 226 | if err != nil { 227 | return nil, errors.Wrap(err, "failed to convert JWT payload to JSON") 228 | } 229 | 230 | // 6. Extract relevant claims for Doorman. 231 | userinfo, err := v.ClaimExtractor.Extract(data) 232 | if err != nil { 233 | return nil, errors.Wrap(err, "failed to extract userinfo from JWT payload") 234 | } 235 | return userinfo, nil 236 | } 237 | 238 | // fromHeader reads the authorization header value. 239 | func fromHeader(r *http.Request) (string, error) { 240 | if authorizationHeader := r.Header.Get("Authorization"); len(authorizationHeader) > 7 && strings.EqualFold(authorizationHeader[0:7], "BEARER ") { 241 | return authorizationHeader[7:], nil 242 | } 243 | return "", fmt.Errorf("token not found") 244 | } 245 | 246 | func downloadJSON(uri string, header http.Header) ([]byte, error) { 247 | client := &http.Client{} 248 | req, _ := http.NewRequest("GET", uri, nil) 249 | if header != nil { 250 | req.Header = header 251 | } 252 | req.Header.Add("Accept", "application/json") 253 | response, err := client.Do(req) 254 | if err != nil { 255 | return nil, errors.Wrap(err, "could not read JSON") 256 | } 257 | if contentHeader := response.Header.Get("Content-Type"); !strings.HasPrefix(contentHeader, "application/json") { 258 | return nil, fmt.Errorf("%s has not a JSON content-type", uri) 259 | } 260 | if response.StatusCode != http.StatusOK { 261 | return nil, fmt.Errorf("server response error (%s)", response.Status) 262 | } 263 | defer response.Body.Close() 264 | data, err := ioutil.ReadAll(response.Body) 265 | if err != nil { 266 | return nil, errors.Wrap(err, "could not read JSON response") 267 | } 268 | return data, nil 269 | } 270 | -------------------------------------------------------------------------------- /doorman/doorman_ladon_test.go: -------------------------------------------------------------------------------- 1 | package doorman 2 | 3 | import ( 4 | "bytes" 5 | "os" 6 | "testing" 7 | 8 | "github.com/gin-gonic/gin" 9 | "github.com/stretchr/testify/assert" 10 | ) 11 | 12 | var sampleConfigs ServicesConfig 13 | 14 | func TestMain(m *testing.M) { 15 | // Load sample policies once 16 | sampleConfigs = ServicesConfig{ 17 | ServiceConfig{ 18 | Service: "https://sample.yaml", 19 | IdentityProvider: "", 20 | Tags: Tags{ 21 | "admins": Principals{"userid:maria"}, 22 | }, 23 | Policies: Policies{ 24 | Policy{ 25 | ID: "1", 26 | Principals: Principals{"userid:foo", "tag:admins"}, 27 | Actions: []string{"update"}, 28 | Resources: []string{"<.*>"}, 29 | Effect: "allow", 30 | }, 31 | Policy{ 32 | ID: "2", 33 | Principals: Principals{"<.*>"}, 34 | Actions: []string{"<.*>"}, 35 | Resources: []string{"<.*>"}, 36 | Conditions: Conditions{ 37 | "planet": Condition{ 38 | Type: "StringEqualCondition", 39 | Options: map[string]interface{}{ 40 | "equals": "mars", 41 | }, 42 | }, 43 | }, 44 | Effect: "deny", 45 | }, 46 | Policy{ 47 | ID: "3", 48 | Principals: Principals{"<.*>"}, 49 | Actions: []string{"read"}, 50 | Resources: []string{"<.*>"}, 51 | Conditions: Conditions{ 52 | "ip": Condition{ 53 | Type: "CIDRCondition", 54 | Options: map[string]interface{}{ 55 | "cidr": "127.0.0.0/8", 56 | }, 57 | }, 58 | }, 59 | Effect: "allow", 60 | }, 61 | Policy{ 62 | ID: "4", 63 | Principals: Principals{"<.*>"}, 64 | Actions: []string{"<.*>"}, 65 | Resources: []string{"<.*>"}, 66 | Conditions: Conditions{ 67 | "owner": Condition{ 68 | Type: "MatchPrincipalsCondition", 69 | }, 70 | }, 71 | Effect: "allow", 72 | }, 73 | Policy{ 74 | ID: "5", 75 | Principals: Principals{"group:admins"}, 76 | Actions: []string{"create"}, 77 | Resources: []string{"<.*>"}, 78 | Conditions: Conditions{ 79 | "domain": Condition{ 80 | Type: "StringMatchCondition", 81 | Options: map[string]interface{}{ 82 | "matches": ".*\\.mozilla\\.org", 83 | }, 84 | }, 85 | }, 86 | Effect: "allow", 87 | }, 88 | Policy{ 89 | ID: "6", 90 | Principals: Principals{"role:editor"}, 91 | Actions: []string{"update"}, 92 | Resources: []string{"pto"}, 93 | Effect: "allow", 94 | }, 95 | }, 96 | }, 97 | } 98 | //Set Gin to Test Mode 99 | gin.SetMode(gin.TestMode) 100 | // Run the other tests 101 | os.Exit(m.Run()) 102 | } 103 | 104 | func sampleDoorman() *LadonDoorman { 105 | doorman := NewDefaultLadon() 106 | doorman.LoadPolicies(sampleConfigs) 107 | return doorman 108 | } 109 | 110 | func TestBadServicesConfig(t *testing.T) { 111 | d := NewDefaultLadon() 112 | 113 | // Duplicated policy ID 114 | err := d.LoadPolicies(ServicesConfig{ 115 | ServiceConfig{ 116 | Service: "a", 117 | Policies: Policies{ 118 | Policy{ 119 | ID: "1", 120 | Effect: "allow", 121 | }, 122 | Policy{ 123 | ID: "1", 124 | Effect: "deny", 125 | }, 126 | }, 127 | }, 128 | }) 129 | assert.NotNil(t, err) 130 | 131 | // Duplicated service 132 | err = d.LoadPolicies(ServicesConfig{ 133 | ServiceConfig{ 134 | Service: "a", 135 | Policies: Policies{ 136 | Policy{ 137 | ID: "1", 138 | Effect: "allow", 139 | }, 140 | }, 141 | }, 142 | ServiceConfig{ 143 | Service: "a", 144 | Policies: Policies{ 145 | Policy{ 146 | ID: "1", 147 | Effect: "allow", 148 | }, 149 | }, 150 | }, 151 | }) 152 | assert.NotNil(t, err) 153 | 154 | // Bad JWT issuer 155 | err = d.LoadPolicies(ServicesConfig{ 156 | ServiceConfig{ 157 | IdentityProvider: "http://perlin-pinpin", 158 | }, 159 | }) 160 | assert.NotNil(t, err) 161 | 162 | // Unknown condition type 163 | err = d.LoadPolicies(ServicesConfig{ 164 | ServiceConfig{ 165 | Service: "a", 166 | Policies: Policies{ 167 | Policy{ 168 | ID: "1", 169 | Conditions: Conditions{ 170 | "owner": Condition{ 171 | Type: "healthy", 172 | }, 173 | }, 174 | Effect: "allow", 175 | }, 176 | }, 177 | }, 178 | }) 179 | assert.NotNil(t, err) 180 | } 181 | 182 | func TestLoadPoliciesTwice(t *testing.T) { 183 | doorman := sampleDoorman() 184 | loaded, _ := doorman.ladons["https://sample.yaml"].Manager.GetAll(0, maxInt) 185 | assert.Equal(t, 6, len(loaded)) 186 | 187 | // Second load. 188 | doorman.LoadPolicies(sampleConfigs) 189 | loaded, _ = doorman.ladons["https://sample.yaml"].Manager.GetAll(0, maxInt) 190 | assert.Equal(t, 6, len(loaded)) 191 | 192 | // Load bad policies, does not affect existing. 193 | err := doorman.LoadPolicies(ServicesConfig{ 194 | ServiceConfig{ 195 | IdentityProvider: "http://perlin-pinpin", 196 | }, 197 | }) 198 | assert.Contains(t, err.Error(), "\"http://perlin-pinpin\" does not use the https:// scheme") 199 | _, ok := doorman.ladons["https://sample.yaml"] 200 | assert.True(t, ok) 201 | } 202 | 203 | func TestIsAllowed(t *testing.T) { 204 | doorman := sampleDoorman() 205 | 206 | // Policy #1 207 | request := &Request{ 208 | Principals: Principals{"userid:foo"}, 209 | Action: "update", 210 | Resource: "server.org/blocklist:onecrl", 211 | } 212 | 213 | // Check service 214 | allowed := doorman.IsAllowed("https://sample.yaml", request) 215 | assert.True(t, allowed) 216 | allowed = doorman.IsAllowed("https://bad.service", request) 217 | assert.False(t, allowed) 218 | } 219 | 220 | func TestExpandPrincipals(t *testing.T) { 221 | doorman := sampleDoorman() 222 | 223 | // Expand principals from tags 224 | principals := doorman.ExpandPrincipals("https://sample.yaml", Principals{"userid:maria"}) 225 | assert.Equal(t, principals, Principals{"userid:maria", "tag:admins"}) 226 | } 227 | 228 | func TestDoormanAllowed(t *testing.T) { 229 | doorman := sampleDoorman() 230 | 231 | for _, request := range []*Request{ 232 | // Policy #1 233 | { 234 | Principals: []string{"userid:foo"}, 235 | Action: "update", 236 | Resource: "server.org/blocklist:onecrl", 237 | }, 238 | // Policy #2 239 | { 240 | Principals: []string{"userid:foo"}, 241 | Action: "update", 242 | Resource: "server.org/blocklist:onecrl", 243 | Context: Context{ 244 | "planet": "Mars", // "mars" is case-sensitive 245 | }, 246 | }, 247 | // Policy #3 248 | { 249 | Principals: []string{"userid:foo"}, 250 | Action: "read", 251 | Resource: "server.org/blocklist:onecrl", 252 | Context: Context{ 253 | "ip": "127.0.0.1", 254 | }, 255 | }, 256 | // Policy #4 257 | { 258 | Principals: []string{"userid:bilbo"}, 259 | Action: "wear", 260 | Resource: "ring", 261 | Context: Context{ 262 | "owner": "userid:bilbo", 263 | }, 264 | }, 265 | // Policy #4 (list of principals) 266 | { 267 | Principals: []string{"userid:bilbo"}, 268 | Action: "wear", 269 | Resource: "ring", 270 | Context: Context{ 271 | "owner": []string{"userid:alice", "userid:bilbo"}, 272 | }, 273 | }, 274 | // Policy #5 275 | { 276 | Principals: []string{"group:admins"}, 277 | Action: "create", 278 | Resource: "dns://", 279 | Context: Context{ 280 | "domain": "kinto.mozilla.org", 281 | }, 282 | }, 283 | } { 284 | assert.Equal(t, true, doorman.IsAllowed("https://sample.yaml", request)) 285 | } 286 | } 287 | 288 | func TestDoormanNotAllowed(t *testing.T) { 289 | doorman := sampleDoorman() 290 | 291 | for _, request := range []*Request{ 292 | // Policy #1 293 | { 294 | Principals: []string{"userid:foo"}, 295 | Action: "delete", 296 | Resource: "server.org/blocklist:onecrl", 297 | Context: Context{}, 298 | }, 299 | // Policy #2 300 | { 301 | Principals: []string{"userid:foo"}, 302 | Action: "update", 303 | Resource: "server.org/blocklist:onecrl", 304 | Context: Context{ 305 | "planet": "mars", 306 | }, 307 | }, 308 | // Policy #3 309 | { 310 | Principals: []string{"userid:foo"}, 311 | Action: "read", 312 | Resource: "server.org/blocklist:onecrl", 313 | Context: Context{ 314 | "ip": "10.0.0.1", 315 | }, 316 | }, 317 | // Policy #4 318 | { 319 | Principals: []string{"userid:gollum"}, 320 | Action: "wear", 321 | Resource: "ring", 322 | Context: Context{ 323 | "owner": "bilbo", 324 | }, 325 | }, 326 | // Policy #5 327 | { 328 | Principals: []string{"group:admins"}, 329 | Action: "create", 330 | Resource: "dns://", 331 | Context: Context{ 332 | "domain": "kinto-storage.org", 333 | }, 334 | }, 335 | // Default 336 | { 337 | Context: Context{}, 338 | }, 339 | } { 340 | // Force context value like in handler. 341 | request.Context["_principals"] = request.Principals 342 | assert.Equal(t, false, doorman.IsAllowed("https://sample.yaml", request)) 343 | } 344 | } 345 | 346 | func TestDoormanAuditLogger(t *testing.T) { 347 | doorman := sampleDoorman() 348 | 349 | var buf bytes.Buffer 350 | doorman.auditLogger().logger.Out = &buf 351 | defer func() { 352 | doorman.auditLogger().logger.Out = os.Stdout 353 | }() 354 | 355 | // Logs when service is bad. 356 | doorman.IsAllowed("bad service", &Request{}) 357 | assert.Contains(t, buf.String(), "\"allowed\":false") 358 | 359 | service := "https://sample.yaml" 360 | 361 | // Logs policies. 362 | buf.Reset() 363 | doorman.IsAllowed(service, &Request{ 364 | Principals: Principals{"userid:any"}, 365 | Action: "any", 366 | Resource: "any", 367 | Context: Context{ 368 | "planet": "mars", 369 | "_principals": Principals{"userid:any"}, 370 | }, 371 | }) 372 | assert.Contains(t, buf.String(), "\"allowed\":false") 373 | assert.Contains(t, buf.String(), "\"policies\":[\"2\"]") 374 | 375 | buf.Reset() 376 | doorman.IsAllowed(service, &Request{ 377 | Principals: Principals{"userid:foo"}, 378 | Action: "update", 379 | Resource: "server.org/blocklist:onecrl", 380 | }) 381 | assert.Contains(t, buf.String(), "\"allowed\":true") 382 | assert.Contains(t, buf.String(), "\"policies\":[\"1\"]") 383 | } 384 | -------------------------------------------------------------------------------- /authn/openid_test.go: -------------------------------------------------------------------------------- 1 | package authn 2 | 3 | import ( 4 | "net/http" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | "github.com/stretchr/testify/require" 9 | ) 10 | 11 | func TestFetchOpenIDConfiguration(t *testing.T) { 12 | // Not available 13 | validator := newOpenIDAuthenticator("https://missing.com") 14 | _, err := validator.config() 15 | require.NotNil(t, err) 16 | assert.Contains(t, err.Error(), "connection refused") 17 | // Bad content-type 18 | validator = newOpenIDAuthenticator("https://mozilla.org") 19 | _, err = validator.config() 20 | require.NotNil(t, err) 21 | assert.Contains(t, err.Error(), "has not a JSON content-type") 22 | // Bad JSON 23 | validator = newOpenIDAuthenticator("https://mozilla.org") 24 | validator.cache.Set("config:https://mozilla.org", []byte("")) 25 | _, err = validator.config() 26 | require.NotNil(t, err) 27 | assert.Contains(t, err.Error(), "invalid character '<'") 28 | // Missing jwks_uri 29 | validator = newOpenIDAuthenticator("https://mozilla.org") 30 | validator.cache.Set("config:https://mozilla.org", []byte("{}")) 31 | _, err = validator.config() 32 | require.NotNil(t, err) 33 | assert.Contains(t, err.Error(), "no jwks_uri attribute in OpenID configuration") 34 | // Good one 35 | validator = newOpenIDAuthenticator("https://auth.mozilla.auth0.com/") 36 | config, err := validator.config() 37 | require.Nil(t, err) 38 | assert.Contains(t, config.JWKSUri, ".well-known/jwks.json") 39 | } 40 | 41 | func TestDownloadKeys(t *testing.T) { 42 | validator := newOpenIDAuthenticator("https://fake.com") 43 | // Bad URL 44 | validator.cache.Set("config:https://fake.com", 45 | []byte("{\"jwks_uri\":\"http://z\"}")) 46 | _, err := validator.jwks() 47 | require.NotNil(t, err) 48 | assert.Contains(t, err.Error(), "no such host") 49 | // Bad content-type 50 | validator.cache.Set("config:https://fake.com", 51 | []byte("{\"jwks_uri\":\"https://httpbin.org/image/png\"}")) 52 | _, err = validator.jwks() 53 | require.NotNil(t, err) 54 | assert.Contains(t, err.Error(), "has not a JSON content-type") 55 | // Bad status 56 | validator.cache.Set("config:https://fake.com", 57 | []byte("{\"jwks_uri\":\"https://httpbin.org/image\"}")) 58 | _, err = validator.jwks() 59 | require.NotNil(t, err) 60 | assert.Contains(t, err.Error(), "server response error") 61 | // Bad JSON 62 | validator.cache.Set("jwks:https://fake.com", []byte("")) 63 | _, err = validator.jwks() 64 | require.NotNil(t, err) 65 | assert.Contains(t, err.Error(), "invalid character '<'") 66 | // Missing Keys attribute 67 | validator.cache.Set("jwks:https://fake.com", []byte("{}")) 68 | _, err = validator.jwks() 69 | require.NotNil(t, err) 70 | assert.Contains(t, err.Error(), "no JWKS found") 71 | // Good one 72 | validator = newOpenIDAuthenticator("https://auth.mozilla.auth0.com") 73 | keys, err := validator.jwks() 74 | require.Nil(t, err) 75 | assert.Equal(t, 1, len(keys.Keys)) 76 | } 77 | 78 | func TestFromHeader(t *testing.T) { 79 | r, _ := http.NewRequest("GET", "/", nil) 80 | 81 | _, err := fromHeader(r) 82 | require.NotNil(t, err) 83 | assert.Equal(t, "token not found", err.Error()) 84 | 85 | r.Header.Set("Authorization", "Basic abc") 86 | _, err = fromHeader(r) 87 | require.NotNil(t, err) 88 | assert.Equal(t, "token not found", err.Error()) 89 | 90 | r.Header.Set("Authorization", "Bearer abc zy") 91 | v, err := fromHeader(r) 92 | require.Nil(t, err) 93 | assert.Equal(t, v, "abc zy") 94 | } 95 | 96 | func TestValidateRequestAccessToken(t *testing.T) { 97 | r, _ := http.NewRequest("GET", "/", nil) 98 | 99 | validator := newOpenIDAuthenticator("https://stub") 100 | 101 | // No header. 102 | _, err := validator.ValidateRequest(r) 103 | require.NotNil(t, err) 104 | assert.Contains(t, err.Error(), "token not found") 105 | // Bad config. 106 | validator.cache.Set("config:https://stub", []byte("<>")) 107 | _, err = validator.ValidateRequest(r) 108 | require.NotNil(t, err) 109 | assert.Contains(t, err.Error(), "token not found") 110 | // Good one from cache 111 | r.Header.Set("Authorization", "Bearer abc") 112 | validator.cache.Set("userinfo:abc", []byte("{\"sub\":\"mary\"}")) 113 | info, err := validator.ValidateRequest(r) 114 | require.Nil(t, err) 115 | assert.Equal(t, info.ID, "mary") 116 | } 117 | 118 | func TestValidateRequestIDToken(t *testing.T) { 119 | goodJWT := "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsImtpZCI6Ik1rWkRORGN5UmtOR1JURkROamxCTmpaRk9FSkJOMFpCTnpKQlFUTkVNRGhDTUVFd05rRkdPQSJ9.eyJuYW1lIjoiTWF0aGlldSBMZXBsYXRyZSIsImdpdmVuX25hbWUiOiJNYXRoaWV1IiwiZmFtaWx5X25hbWUiOiJMZXBsYXRyZSIsIm5pY2tuYW1lIjoiTWF0aGlldSBMZXBsYXRyZSIsInBpY3R1cmUiOiJodHRwczovL3MuZ3JhdmF0YXIuY29tL2F2YXRhci85NzE5N2YwMTFhM2Q5ZDQ5NGFlODEzNTY2ZjI0Njc5YT9zPTQ4MCZyPXBnJmQ9aHR0cHMlM0ElMkYlMkZjZG4uYXV0aDAuY29tJTJGYXZhdGFycyUyRm1sLnBuZyIsInVwZGF0ZWRfYXQiOiIyMDE3LTEyLTA0VDE1OjUyOjMzLjc2MVoiLCJpc3MiOiJodHRwczovL2F1dGgubW96aWxsYS5hdXRoMC5jb20vIiwic3ViIjoiYWR8TW96aWxsYS1MREFQfG1sZXBsYXRyZSIsImF1ZCI6IlNMb2NmN1NhMWliZDVHTkpNTXFPNTM5ZzdjS3ZXQk9JIiwiZXhwIjoxNTEzMDA3NTcwLCJpYXQiOjE1MTI0MDI3NzAsImFtciI6WyJtZmEiXSwiYWNyIjoiaHR0cDovL3NjaGVtYXMub3BlbmlkLm5ldC9wYXBlL3BvbGljaWVzLzIwMDcvMDYvbXVsdGktZmFjdG9yIiwibm9uY2UiOiJQRkxyLmxtYWhCQWRYaEVSWm0zYVFxc2ZuWjhwcWt0VSIsImF0X2hhc2giOiJTN0Rha1BrZVA0Tnk4SWpTOGxnMHJBIiwiaHR0cHM6Ly9zc28ubW96aWxsYS5jb20vY2xhaW0vZ3JvdXBzIjpbIkludHJhbmV0V2lraSIsIlN0YXRzRGFzaGJvYXJkIiwicGhvbmVib29rX2FjY2VzcyIsImNvcnAtdnBuIiwidnBuX2NvcnAiLCJ2cG5fZGVmYXVsdCIsIkNsb3Vkc2VydmljZXNXaWtpIiwidGVhbV9tb2NvIiwiaXJjY2xvdWQiLCJva3RhX21mYSIsImNsb3Vkc2VydmljZXNfZGV2IiwidnBuX2tpbnRvMV9zdGFnZSIsInZwbl9raW50bzFfcHJvZCIsImVnZW5jaWFfZGUiLCJhY3RpdmVfc2NtX2xldmVsXzEiLCJhbGxfc2NtX2xldmVsXzEiLCJzZXJ2aWNlX3NhZmFyaWJvb2tzIl0sImh0dHBzOi8vc3NvLm1vemlsbGEuY29tL2NsYWltL2VtYWlscyI6WyJtbGVwbGF0cmVAbW96aWxsYS5jb20iLCJtYXRoaWV1QG1vemlsbGEuY29tIiwibWF0aGlldS5sZXBsYXRyZUBtb3ppbGxhLmNvbSJdLCJodHRwczovL3Nzby5tb3ppbGxhLmNvbS9jbGFpbS9kbiI6Im1haWw9bWxlcGxhdHJlQG1vemlsbGEuY29tLG89Y29tLGRjPW1vemlsbGEiLCJodHRwczovL3Nzby5tb3ppbGxhLmNvbS9jbGFpbS9vcmdhbml6YXRpb25Vbml0cyI6Im1haWw9bWxlcGxhdHJlQG1vemlsbGEuY29tLG89Y29tLGRjPW1vemlsbGEiLCJodHRwczovL3Nzby5tb3ppbGxhLmNvbS9jbGFpbS9lbWFpbF9hbGlhc2VzIjpbIm1hdGhpZXVAbW96aWxsYS5jb20iLCJtYXRoaWV1LmxlcGxhdHJlQG1vemlsbGEuY29tIl0sImh0dHBzOi8vc3NvLm1vemlsbGEuY29tL2NsYWltL19IUkRhdGEiOnsicGxhY2Vob2xkZXIiOiJlbXB0eSJ9fQ.MK3Z1Nj15MfbM2TcO4FWVTTYPqAbUhL26pYOFa92mPnEUR2W_oJhwoZ8Vwq7dJcvTZfPq-aZKBnqHoPHHYlQbtaqfflhHmY9iRH0aPlxLQed_WVem4YqMn9xw0az4xHnf0UlzLU58kI97bqUFvvzs0fg_OTdDdO3owVUcaZrG8-xalCqQGQqwTfiH514gxeZ_Ki6610HSVDvpPvmODWPz87IDdgS6WkyM-SyAc3aYukP38aqRo-PUjEdpGbOtV_T_W2x8A3yQDxu0Bcq0WJz-FUEu2BHq1Vn6rmLm7BVYjDD6rYseusp8M0bvTfvXA-9OhJWGAAh6KrN9fnw7r30LQ" 120 | r, _ := http.NewRequest("GET", "/", nil) 121 | r.Header.Set("Authorization", "Bearer "+goodJWT) 122 | 123 | // Fail to fetch JWKS 124 | validator := newOpenIDAuthenticator("https://perlinpimpin.com") 125 | 126 | _, err := validator.ValidateRequest(r) 127 | require.NotNil(t, err) 128 | assert.Contains(t, err.Error(), "no such host") 129 | 130 | validator = newOpenIDAuthenticator("https://auth.mozilla.auth0.com/") 131 | 132 | // Cannot extract JWT 133 | r.Header.Set("Authorization", "Bearer abc") 134 | _, err = validator.ValidateRequest(r) 135 | require.NotNil(t, err) 136 | assert.Contains(t, err.Error(), "compact JWS format must have three parts") 137 | 138 | // Unknown public key 139 | r.Header.Set("Authorization", "Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsImtpZCI6ImFiYyJ9.abc.123") 140 | _, err = validator.ValidateRequest(r) 141 | require.NotNil(t, err) 142 | assert.Contains(t, err.Error(), "no JWT key with id \"abc\"") 143 | 144 | // // Invalid algorithm 145 | r.Header.Set("Authorization", "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9.TJVA95OrM7E2cBab30RMHrHDcEfxjoYZgeFONFh7HgQ") 146 | _, err = validator.ValidateRequest(r) 147 | require.NotNil(t, err) 148 | assert.Contains(t, err.Error(), "invalid algorithm") 149 | 150 | // Bad signature 151 | r.Header.Set("Authorization", "Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsImtpZCI6Ik1rWkRORGN5UmtOR1JURkROamxCTmpaRk9FSkJOMFpCTnpKQlFUTkVNRGhDTUVFd05rRkdPQSJ9.eyJuYW1lIjoiTWF0aGlldSBMZXBsYXRyZSIsImdpdmVuX25hbWUiOiJNYXRoaWV1IiwiZmFtaWx5X25hbWUiOiJMZXBsYXRyZSIsIm5pY2tuYW1lIjoiTWF0aGlldSBMZXBsYXRyZSIsInBpY3R1cmUiOiJodHRwczovL3MuZ3JhdmF0YXIuY29tL2F2YXRhci85NzE5N2YwMTFhM2Q5ZDQ5NGFlODEzNTY2ZjI0Njc5YT9zPTQ4MCZyPXBnJmQ9aHR0cHMlM0ElMkYlMkZjZG4uYXV0aDAuY29tJTJGYXZhdGFycyUyRm1sLnBuZyIsInVwZGF0ZWRfYXQiOiIyMDE3LTEyLTA0VDE1OjUyOjMzLjc2MVoiLCJpc3MiOiJodHRwczovL2F1dGgubW96aWxsYS5hdXRoMC5jb20vIiwic3ViIjoiYWR8TW96aWxsYS1MREFQfG1sZXBsYXRyZSIsImF1ZCI6IlNMb2NmN1NhMWliZDVHTkpNTXFPNTM5ZzdjS3ZXQk9JIiwiZXhwIjoxNTEzMDA3NTcwLCJpYXQiOjE1MTI0MDI3NzAsImFtciI6WyJtZmEiXSwiYWNyIjoiaHR0cDovL3NjaGVtYXMub3BlbmlkLm5ldC9wYXBlL3BvbGljaWVzLzIwMDcvMDYvbXVsdGktZmFjdG9yIiwibm9uY2UiOiJQRkxyLmxtYWhCQWRYaEVSWm0zYVFxc2ZuWjhwcWt0VSIsImF0X2hhc2giOiJTN0Rha1BrZVA0Tnk4SWpTOGxnMHJBIiwiaHR0cHM6Ly9zc28ubW96aWxsYS5jb20vY2xhaW0vZ3JvdXBzIjpbIkludHJhbmV0V2lraSIsIlN0YXRzRGFzaGJvYXJkIiwicGhvbmVib29rX2FjY2VzcyIsImNvcnAtdnBuIiwidnBuX2NvcnAiLCJ2cG5fZGVmYXVsdCIsIkNsb3Vkc2VydmljZXNXaWtpIiwidGVhbV9tb2NvIiwiaXJjY2xvdWQiLCJva3RhX21mYSIsImNsb3Vkc2VydmljZXNfZGV2IiwidnBuX2tpbnRvMV9zdGFnZSIsInZwbl9raW50bzFfcHJvZCIsImVnZW5jaWFfZGUiLCJhY3RpdmVfc2NtX2xldmVsXzEiLCJhbGxfc2NtX2xldmVsXzEiLCJzZXJ2aWNlX3NhZmFyaWJvb2tzIl0sImh0dHBzOi8vc3NvLm1vemlsbGEuY29tL2NsYWltL2VtYWlscyI6WyJtbGVwbGF0cmVAbW96aWxsYS5jb20iLCJtYXRoaWV1QG1vemlsbGEuY29tIiwibWF0aGlldS5sZXBsYXRyZUBtb3ppbGxhLmNvbSJdLCJodHRwczovL3Nzby5tb3ppbGxhLmNvbS9jbGFpbS9kbiI6Im1haWw9bWxlcGxhdHJlQG1vemlsbGEuY29tLG89Y29tLGRjPW1vemlsbGEiLCJodHRwczovL3Nzby5tb3ppbGxhLmNvbS9jbGFpbS9vcmdhbml6YXRpb25Vbml0cyI6Im1haWw9bWxlcGxhdHJlQG1vemlsbGEuY29tLG89Y29tLGRjPW1vemlsbGEiLCJodHRwczovL3Nzby5tb3ppbGxhLmNvbS9jbGFpbS9lbWFpbF9hbGlhc2VzIjpbIm1hdGhpZXVAbW96aWxsYS5jb20iLCJtYXRoaWV1LmxlcGxhdHJlQG1vemlsbGEuY29tIl0sImh0dHBzOi8vc3NvLm1vemlsbGEuY29tL2NsYWltL19IUkRhdGEiOnsicGxhY2Vob2xkZXIiOiJlbXB0eSJ9fQ.123") 152 | _, err = validator.ValidateRequest(r) 153 | require.NotNil(t, err) 154 | assert.Contains(t, err.Error(), "error in cryptographic primitive") 155 | 156 | // Invalid audience 157 | r.Header.Set("Authorization", "Bearer "+goodJWT) 158 | _, err = validator.ValidateRequest(r) 159 | require.NotNil(t, err) 160 | assert.Contains(t, err.Error(), "validation failed, invalid audience claim") 161 | 162 | // Valid claims, expired token. 163 | r.Header.Set("Origin", "SLocf7Sa1ibd5GNJMMqO539g7cKvWBOI") 164 | _, err = validator.ValidateRequest(r) 165 | require.NotNil(t, err) 166 | assert.Contains(t, err.Error(), "validation failed, token is expired") 167 | 168 | // Disable expiration verification. 169 | validator.envTest = true 170 | info, err := validator.ValidateRequest(r) 171 | require.Nil(t, err) 172 | assert.Equal(t, info.ID, "ad|Mozilla-LDAP|mleplatre") 173 | assert.Contains(t, info.Groups, "irccloud") 174 | } 175 | 176 | func BenchmarkParseKeys(b *testing.B) { 177 | // Warm cache. 178 | validator := newOpenIDAuthenticator("https://auth.mozilla.auth0.com") 179 | validator.jwks() 180 | b.ResetTimer() 181 | // Bench parsing of cache bytes into keys objects. 182 | for i := 0; i < b.N; i++ { 183 | validator.jwks() 184 | } 185 | } 186 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Mozilla Public License Version 2.0 2 | ================================== 3 | 4 | 1. Definitions 5 | -------------- 6 | 7 | 1.1. "Contributor" 8 | means each individual or legal entity that creates, contributes to 9 | the creation of, or owns Covered Software. 10 | 11 | 1.2. "Contributor Version" 12 | means the combination of the Contributions of others (if any) used 13 | by a Contributor and that particular Contributor's Contribution. 14 | 15 | 1.3. "Contribution" 16 | means Covered Software of a particular Contributor. 17 | 18 | 1.4. "Covered Software" 19 | means Source Code Form to which the initial Contributor has attached 20 | the notice in Exhibit A, the Executable Form of such Source Code 21 | Form, and Modifications of such Source Code Form, in each case 22 | including portions thereof. 23 | 24 | 1.5. "Incompatible With Secondary Licenses" 25 | means 26 | 27 | (a) that the initial Contributor has attached the notice described 28 | in Exhibit B to the Covered Software; or 29 | 30 | (b) that the Covered Software was made available under the terms of 31 | version 1.1 or earlier of the License, but not also under the 32 | terms of a Secondary License. 33 | 34 | 1.6. "Executable Form" 35 | means any form of the work other than Source Code Form. 36 | 37 | 1.7. "Larger Work" 38 | means a work that combines Covered Software with other material, in 39 | a separate file or files, that is not Covered Software. 40 | 41 | 1.8. "License" 42 | means this document. 43 | 44 | 1.9. "Licensable" 45 | means having the right to grant, to the maximum extent possible, 46 | whether at the time of the initial grant or subsequently, any and 47 | all of the rights conveyed by this License. 48 | 49 | 1.10. "Modifications" 50 | means any of the following: 51 | 52 | (a) any file in Source Code Form that results from an addition to, 53 | deletion from, or modification of the contents of Covered 54 | Software; or 55 | 56 | (b) any new file in Source Code Form that contains any Covered 57 | Software. 58 | 59 | 1.11. "Patent Claims" of a Contributor 60 | means any patent claim(s), including without limitation, method, 61 | process, and apparatus claims, in any patent Licensable by such 62 | Contributor that would be infringed, but for the grant of the 63 | License, by the making, using, selling, offering for sale, having 64 | made, import, or transfer of either its Contributions or its 65 | Contributor Version. 66 | 67 | 1.12. "Secondary License" 68 | means either the GNU General Public License, Version 2.0, the GNU 69 | Lesser General Public License, Version 2.1, the GNU Affero General 70 | Public License, Version 3.0, or any later versions of those 71 | licenses. 72 | 73 | 1.13. "Source Code Form" 74 | means the form of the work preferred for making modifications. 75 | 76 | 1.14. "You" (or "Your") 77 | means an individual or a legal entity exercising rights under this 78 | License. For legal entities, "You" includes any entity that 79 | controls, is controlled by, or is under common control with You. For 80 | purposes of this definition, "control" means (a) the power, direct 81 | or indirect, to cause the direction or management of such entity, 82 | whether by contract or otherwise, or (b) ownership of more than 83 | fifty percent (50%) of the outstanding shares or beneficial 84 | ownership of such entity. 85 | 86 | 2. License Grants and Conditions 87 | -------------------------------- 88 | 89 | 2.1. Grants 90 | 91 | Each Contributor hereby grants You a world-wide, royalty-free, 92 | non-exclusive license: 93 | 94 | (a) under intellectual property rights (other than patent or trademark) 95 | Licensable by such Contributor to use, reproduce, make available, 96 | modify, display, perform, distribute, and otherwise exploit its 97 | Contributions, either on an unmodified basis, with Modifications, or 98 | as part of a Larger Work; and 99 | 100 | (b) under Patent Claims of such Contributor to make, use, sell, offer 101 | for sale, have made, import, and otherwise transfer either its 102 | Contributions or its Contributor Version. 103 | 104 | 2.2. Effective Date 105 | 106 | The licenses granted in Section 2.1 with respect to any Contribution 107 | become effective for each Contribution on the date the Contributor first 108 | distributes such Contribution. 109 | 110 | 2.3. Limitations on Grant Scope 111 | 112 | The licenses granted in this Section 2 are the only rights granted under 113 | this License. No additional rights or licenses will be implied from the 114 | distribution or licensing of Covered Software under this License. 115 | Notwithstanding Section 2.1(b) above, no patent license is granted by a 116 | Contributor: 117 | 118 | (a) for any code that a Contributor has removed from Covered Software; 119 | or 120 | 121 | (b) for infringements caused by: (i) Your and any other third party's 122 | modifications of Covered Software, or (ii) the combination of its 123 | Contributions with other software (except as part of its Contributor 124 | Version); or 125 | 126 | (c) under Patent Claims infringed by Covered Software in the absence of 127 | its Contributions. 128 | 129 | This License does not grant any rights in the trademarks, service marks, 130 | or logos of any Contributor (except as may be necessary to comply with 131 | the notice requirements in Section 3.4). 132 | 133 | 2.4. Subsequent Licenses 134 | 135 | No Contributor makes additional grants as a result of Your choice to 136 | distribute the Covered Software under a subsequent version of this 137 | License (see Section 10.2) or under the terms of a Secondary License (if 138 | permitted under the terms of Section 3.3). 139 | 140 | 2.5. Representation 141 | 142 | Each Contributor represents that the Contributor believes its 143 | Contributions are its original creation(s) or it has sufficient rights 144 | to grant the rights to its Contributions conveyed by this License. 145 | 146 | 2.6. Fair Use 147 | 148 | This License is not intended to limit any rights You have under 149 | applicable copyright doctrines of fair use, fair dealing, or other 150 | equivalents. 151 | 152 | 2.7. Conditions 153 | 154 | Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted 155 | in Section 2.1. 156 | 157 | 3. Responsibilities 158 | ------------------- 159 | 160 | 3.1. Distribution of Source Form 161 | 162 | All distribution of Covered Software in Source Code Form, including any 163 | Modifications that You create or to which You contribute, must be under 164 | the terms of this License. You must inform recipients that the Source 165 | Code Form of the Covered Software is governed by the terms of this 166 | License, and how they can obtain a copy of this License. You may not 167 | attempt to alter or restrict the recipients' rights in the Source Code 168 | Form. 169 | 170 | 3.2. Distribution of Executable Form 171 | 172 | If You distribute Covered Software in Executable Form then: 173 | 174 | (a) such Covered Software must also be made available in Source Code 175 | Form, as described in Section 3.1, and You must inform recipients of 176 | the Executable Form how they can obtain a copy of such Source Code 177 | Form by reasonable means in a timely manner, at a charge no more 178 | than the cost of distribution to the recipient; and 179 | 180 | (b) You may distribute such Executable Form under the terms of this 181 | License, or sublicense it under different terms, provided that the 182 | license for the Executable Form does not attempt to limit or alter 183 | the recipients' rights in the Source Code Form under this License. 184 | 185 | 3.3. Distribution of a Larger Work 186 | 187 | You may create and distribute a Larger Work under terms of Your choice, 188 | provided that You also comply with the requirements of this License for 189 | the Covered Software. If the Larger Work is a combination of Covered 190 | Software with a work governed by one or more Secondary Licenses, and the 191 | Covered Software is not Incompatible With Secondary Licenses, this 192 | License permits You to additionally distribute such Covered Software 193 | under the terms of such Secondary License(s), so that the recipient of 194 | the Larger Work may, at their option, further distribute the Covered 195 | Software under the terms of either this License or such Secondary 196 | License(s). 197 | 198 | 3.4. Notices 199 | 200 | You may not remove or alter the substance of any license notices 201 | (including copyright notices, patent notices, disclaimers of warranty, 202 | or limitations of liability) contained within the Source Code Form of 203 | the Covered Software, except that You may alter any license notices to 204 | the extent required to remedy known factual inaccuracies. 205 | 206 | 3.5. Application of Additional Terms 207 | 208 | You may choose to offer, and to charge a fee for, warranty, support, 209 | indemnity or liability obligations to one or more recipients of Covered 210 | Software. However, You may do so only on Your own behalf, and not on 211 | behalf of any Contributor. You must make it absolutely clear that any 212 | such warranty, support, indemnity, or liability obligation is offered by 213 | You alone, and You hereby agree to indemnify every Contributor for any 214 | liability incurred by such Contributor as a result of warranty, support, 215 | indemnity or liability terms You offer. You may include additional 216 | disclaimers of warranty and limitations of liability specific to any 217 | jurisdiction. 218 | 219 | 4. Inability to Comply Due to Statute or Regulation 220 | --------------------------------------------------- 221 | 222 | If it is impossible for You to comply with any of the terms of this 223 | License with respect to some or all of the Covered Software due to 224 | statute, judicial order, or regulation then You must: (a) comply with 225 | the terms of this License to the maximum extent possible; and (b) 226 | describe the limitations and the code they affect. Such description must 227 | be placed in a text file included with all distributions of the Covered 228 | Software under this License. Except to the extent prohibited by statute 229 | or regulation, such description must be sufficiently detailed for a 230 | recipient of ordinary skill to be able to understand it. 231 | 232 | 5. Termination 233 | -------------- 234 | 235 | 5.1. The rights granted under this License will terminate automatically 236 | if You fail to comply with any of its terms. However, if You become 237 | compliant, then the rights granted under this License from a particular 238 | Contributor are reinstated (a) provisionally, unless and until such 239 | Contributor explicitly and finally terminates Your grants, and (b) on an 240 | ongoing basis, if such Contributor fails to notify You of the 241 | non-compliance by some reasonable means prior to 60 days after You have 242 | come back into compliance. Moreover, Your grants from a particular 243 | Contributor are reinstated on an ongoing basis if such Contributor 244 | notifies You of the non-compliance by some reasonable means, this is the 245 | first time You have received notice of non-compliance with this License 246 | from such Contributor, and You become compliant prior to 30 days after 247 | Your receipt of the notice. 248 | 249 | 5.2. If You initiate litigation against any entity by asserting a patent 250 | infringement claim (excluding declaratory judgment actions, 251 | counter-claims, and cross-claims) alleging that a Contributor Version 252 | directly or indirectly infringes any patent, then the rights granted to 253 | You by any and all Contributors for the Covered Software under Section 254 | 2.1 of this License shall terminate. 255 | 256 | 5.3. In the event of termination under Sections 5.1 or 5.2 above, all 257 | end user license agreements (excluding distributors and resellers) which 258 | have been validly granted by You or Your distributors under this License 259 | prior to termination shall survive termination. 260 | 261 | ************************************************************************ 262 | * * 263 | * 6. Disclaimer of Warranty * 264 | * ------------------------- * 265 | * * 266 | * Covered Software is provided under this License on an "as is" * 267 | * basis, without warranty of any kind, either expressed, implied, or * 268 | * statutory, including, without limitation, warranties that the * 269 | * Covered Software is free of defects, merchantable, fit for a * 270 | * particular purpose or non-infringing. The entire risk as to the * 271 | * quality and performance of the Covered Software is with You. * 272 | * Should any Covered Software prove defective in any respect, You * 273 | * (not any Contributor) assume the cost of any necessary servicing, * 274 | * repair, or correction. This disclaimer of warranty constitutes an * 275 | * essential part of this License. No use of any Covered Software is * 276 | * authorized under this License except under this disclaimer. * 277 | * * 278 | ************************************************************************ 279 | 280 | ************************************************************************ 281 | * * 282 | * 7. Limitation of Liability * 283 | * -------------------------- * 284 | * * 285 | * Under no circumstances and under no legal theory, whether tort * 286 | * (including negligence), contract, or otherwise, shall any * 287 | * Contributor, or anyone who distributes Covered Software as * 288 | * permitted above, be liable to You for any direct, indirect, * 289 | * special, incidental, or consequential damages of any character * 290 | * including, without limitation, damages for lost profits, loss of * 291 | * goodwill, work stoppage, computer failure or malfunction, or any * 292 | * and all other commercial damages or losses, even if such party * 293 | * shall have been informed of the possibility of such damages. This * 294 | * limitation of liability shall not apply to liability for death or * 295 | * personal injury resulting from such party's negligence to the * 296 | * extent applicable law prohibits such limitation. Some * 297 | * jurisdictions do not allow the exclusion or limitation of * 298 | * incidental or consequential damages, so this exclusion and * 299 | * limitation may not apply to You. * 300 | * * 301 | ************************************************************************ 302 | 303 | 8. Litigation 304 | ------------- 305 | 306 | Any litigation relating to this License may be brought only in the 307 | courts of a jurisdiction where the defendant maintains its principal 308 | place of business and such litigation shall be governed by laws of that 309 | jurisdiction, without reference to its conflict-of-law provisions. 310 | Nothing in this Section shall prevent a party's ability to bring 311 | cross-claims or counter-claims. 312 | 313 | 9. Miscellaneous 314 | ---------------- 315 | 316 | This License represents the complete agreement concerning the subject 317 | matter hereof. If any provision of this License is held to be 318 | unenforceable, such provision shall be reformed only to the extent 319 | necessary to make it enforceable. Any law or regulation which provides 320 | that the language of a contract shall be construed against the drafter 321 | shall not be used to construe this License against a Contributor. 322 | 323 | 10. Versions of the License 324 | --------------------------- 325 | 326 | 10.1. New Versions 327 | 328 | Mozilla Foundation is the license steward. Except as provided in Section 329 | 10.3, no one other than the license steward has the right to modify or 330 | publish new versions of this License. Each version will be given a 331 | distinguishing version number. 332 | 333 | 10.2. Effect of New Versions 334 | 335 | You may distribute the Covered Software under the terms of the version 336 | of the License under which You originally received the Covered Software, 337 | or under the terms of any subsequent version published by the license 338 | steward. 339 | 340 | 10.3. Modified Versions 341 | 342 | If you create software not governed by this License, and you want to 343 | create a new license for such software, you may create and use a 344 | modified version of this License if you rename the license and remove 345 | any references to the name of the license steward (except to note that 346 | such modified license differs from this License). 347 | 348 | 10.4. Distributing Source Code Form that is Incompatible With Secondary 349 | Licenses 350 | 351 | If You choose to distribute Source Code Form that is Incompatible With 352 | Secondary Licenses under the terms of this version of the License, the 353 | notice described in Exhibit B of this License must be attached. 354 | 355 | Exhibit A - Source Code Form License Notice 356 | ------------------------------------------- 357 | 358 | This Source Code Form is subject to the terms of the Mozilla Public 359 | License, v. 2.0. If a copy of the MPL was not distributed with this 360 | file, You can obtain one at http://mozilla.org/MPL/2.0/. 361 | 362 | If it is not possible or desirable to put the notice in a particular 363 | file, then You may include the notice in a location (such as a LICENSE 364 | file in a relevant directory) where a recipient would be likely to look 365 | for such a notice. 366 | 367 | You may add additional accurate notices of copyright ownership. 368 | 369 | Exhibit B - "Incompatible With Secondary Licenses" Notice 370 | --------------------------------------------------------- 371 | 372 | This Source Code Form is "Incompatible With Secondary Licenses", as 373 | defined by the Mozilla Public License, v. 2.0. 374 | 375 | --------------------------------------------------------------------------------