diff --git a/docker-compose.yml b/docker-compose.yml index c75a3f5..068724e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -5,4 +5,13 @@ services: context: . image: kenfdev/opa-api-auth-go ports: - - 1323:1323 \ No newline at end of file + - 1323:1323 + environment: + PDP_ENDPOINT: http://pdp:8181/v1/data/httpapi/authz/allow + pdp: + image: openpolicyagent/opa:0.12.0 + ports: + - 8181:8181 + volumes: + - ./opa:/etc/opt/opa + command: ["run", "--server", "/etc/opt/opa/authz.rego"] diff --git a/gateway/policy.go b/gateway/policy.go index abb4fcf..754bc9c 100644 --- a/gateway/policy.go +++ b/gateway/policy.go @@ -1,82 +1,103 @@ package gateway import ( + "bytes" + "encoding/json" "github.com/dgrijalva/jwt-go" - "github.com/kenfdev/opa-api-auth-go/entity" "github.com/labstack/echo" "github.com/sirupsen/logrus" - "regexp" + "io/ioutil" + "net/http" + "strings" ) type PolicyGateway interface { Ask(echo.Context) bool } -type PolicyLocalGateway struct { - getSalaryPathRegex *regexp.Regexp +type opaRequest struct { + // Input wraps the OPA request (https://www.openpolicyagent.org/docs/latest/rest-api/#get-a-document-with-input) + Input *opaInput `json:"input"` } -func NewPolicyLocalGateway() *PolicyLocalGateway { - return &PolicyLocalGateway{ - getSalaryPathRegex: regexp.MustCompile("GET /finance/salary/.*"), - } +type opaInput struct { + // The token of the requester + Token string `json:"token"` + // The path to which the request was made split to an array + Path []string `json:"path"` + // The HTTP Method + Method string `json:"method"` } -// Ask checks if an action is permitted as it inspects the request context -func (gw *PolicyLocalGateway) Ask(c echo.Context) bool { - path := c.Request().Method + " " + c.Path() +type opaResponse struct { + Result bool `json:"result"` +} - raw := c.Get("token").(*jwt.Token) - claims := raw.Claims.(*entity.TokenClaims) +// PolicyOpaGateway makes policy decision requests to OPA +type PolicyOpaGateway struct { + endpoint string +} - var allow = false - switch { - case gw.getSalaryPathRegex.MatchString(path): - allow = gw.checkGETSalary(c, claims) +func NewPolicyOpaGateway(endpoint string) *PolicyOpaGateway { + return &PolicyOpaGateway{ + endpoint: endpoint, } - - return allow } -func (gw *PolicyLocalGateway) checkGETSalary(c echo.Context, claims *entity.TokenClaims) bool { - userID := c.Param("id") - logrus.WithFields(logrus.Fields{ - "userID": userID, - "claims": claims, - }).Info("Checking GET salary policies") - - if yes := gw.checkIfOwner(userID, claims); yes { - logrus.Info("Allowing because requester is the owner") - return true +// Ask requests to OPA with required inputs and returns the decision made by OPA +func (gw *PolicyOpaGateway) Ask(c echo.Context) bool { + token := c.Get("token").(*jwt.Token) + // After splitting, the first element isn't necessary + // "/finance/salary/alice" -> ["", "finance", "salary", "alice"] + paths := strings.Split(c.Request().RequestURI, "/")[1:] + method := c.Request().Method + + // create input to send to OPA + input := &opaInput{ + Token: token.Raw, + Path: paths, + Method: method, } - - if yes := gw.checkIfSubordinate(userID, claims); yes { - logrus.Info("Allowing because target is a subordinate of requester") - return true + opaRequest := &opaRequest{ + Input: input, } - if yes := gw.checkIfHR(claims); yes { - logrus.Info("Allowing because requester is a member of HR") - return true + logrus.WithFields(logrus.Fields{ + "token": input.Token, + "path": input.Path, + "method": input.Method, + }).Info("Requesting PDP for decision") + + requestBody, err := json.Marshal(opaRequest) + if err != nil { + logrus.WithFields(logrus.Fields{"error": err}).Error("Marshalling request body failed") + return false } - logrus.Info("Denying request") - return false -} + // request OPA + resp, err := http.Post(gw.endpoint, "application/json", bytes.NewBuffer(requestBody)) + if err != nil { + logrus.WithFields(logrus.Fields{"error": err}).Error("PDP Request failed") + return false + } + defer resp.Body.Close() -func (*PolicyLocalGateway) checkIfOwner(userID string, claims *entity.TokenClaims) bool { - return userID == claims.User -} + body, err := ioutil.ReadAll(resp.Body) + if err != nil { + logrus.WithFields(logrus.Fields{"error": err}).Error("Reading body of response failed") + return false + } -func (*PolicyLocalGateway) checkIfSubordinate(userID string, claims *entity.TokenClaims) bool { - for _, subordinate := range claims.Subordinates { - if subordinate == userID { - return true - } + var opaResponse opaResponse + err = json.Unmarshal(body, &opaResponse) + if err != nil { + logrus.WithFields(logrus.Fields{"error": err}).Error("Unmarshalling response body failed") + return false } - return false -} -func (*PolicyLocalGateway) checkIfHR(claims *entity.TokenClaims) bool { - return claims.BelongsToHR + logrus.WithFields(logrus.Fields{ + "result": opaResponse.Result, + }).Info("Decision") + + return opaResponse.Result } diff --git a/main.go b/main.go index 6e8421e..d0ceec0 100644 --- a/main.go +++ b/main.go @@ -7,13 +7,15 @@ import ( localmiddleware "github.com/kenfdev/opa-api-auth-go/middleware" "github.com/labstack/echo" "github.com/labstack/echo/middleware" + "os" ) func main() { e := echo.New() // inits - policyGateway := gateway.NewPolicyLocalGateway() + endpoint := os.Getenv("PDP_ENDPOINT") + policyGateway := gateway.NewPolicyOpaGateway(endpoint) salaryGateway := gateway.NewSalaryInMemoryGateway() // middleware diff --git a/opa/authz.rego b/opa/authz.rego new file mode 100644 index 0000000..204167e --- /dev/null +++ b/opa/authz.rego @@ -0,0 +1,42 @@ +package httpapi.authz + +# io.jwt.decode_verify +# https://www.openpolicyagent.org/docs/latest/language-reference/#tokens +token = t { + [valid, _, payload] = io.jwt.decode_verify(input.token, { "secret": "secret" }) + t := { + "valid": valid, + "payload": payload + } +} + +default allow = false + +# Allow users to get their own salaries. +allow { + token.valid + + some username + input.method == "GET" + input.path = ["finance", "salary", username] + token.payload.user == username +} + +# Allow managers to get their subordinate's salaries. +allow { + token.valid + + some username + input.method == "GET" + input.path = ["finance", "salary", username] + token.payload.subordinates[_] == username +} + +# Allow HR members to get anyone's salary. +allow { + token.valid + + input.method == "GET" + input.path = ["finance", "salary", _] + token.payload.hr == true +} \ No newline at end of file