From 75c353f0ad73bd83ed18e92857dd99a103bb47e3 Mon Sep 17 00:00:00 2001 From: Liron Levin Date: Thu, 12 Nov 2015 13:06:47 +0200 Subject: [PATCH] Docker authorization plug-in infrastructure enables extending the functionality of the Docker daemon with respect to user authorization. The infrastructure enables registering a set of external authorization plug-in. Each plug-in receives information about the user and the request and decides whether to allow or deny the request. Only in case all plug-ins allow accessing the resource the access is granted. Each plug-in operates as a separate service, and registers with Docker through general (plug-ins API) [https://blog.docker.com/2015/06/extending-docker-with-plugins/]. No Docker daemon recompilation is required in order to add / remove an authentication plug-in. Each plug-in is notified twice for each operation: 1) before the operation is performed and, 2) before the response is returned to the client. The plug-ins can modify the response that is returned to the client. The authorization depends on the authorization effort that takes place in parallel [https://github.com/docker/docker/issues/13697]. This is the official issue of the authorization effort: https://github.com/docker/docker/issues/14674 (Here)[https://github.com/rhatdan/docker-rbac] you can find an open document that discusses a default RBAC plug-in for Docker. Signed-off-by: Liron Levin Added container create flow test and extended the verification for ps --- api/server/middleware.go | 35 +++ api/server/server.go | 17 +- daemon/config.go | 2 + docker/daemon.go | 5 +- docs/extend/authorization.md | 5 +- integration-cli/docker_cli_authz_unix_test.go | 228 ++++++++++++++++++ integration-cli/docker_cli_help_test.go | 2 +- pkg/authorization/api.go | 52 ++++ pkg/authorization/api.md | 83 +++++++ pkg/authorization/authz.go | 159 ++++++++++++ pkg/authorization/authz_test.go | 220 +++++++++++++++++ pkg/authorization/plugin.go | 87 +++++++ pkg/authorization/response.go | 140 +++++++++++ 13 files changed, 1023 insertions(+), 12 deletions(-) create mode 100644 integration-cli/docker_cli_authz_unix_test.go create mode 100644 pkg/authorization/api.go create mode 100644 pkg/authorization/api.md create mode 100644 pkg/authorization/authz.go create mode 100644 pkg/authorization/authz_test.go create mode 100644 pkg/authorization/plugin.go create mode 100644 pkg/authorization/response.go diff --git a/api/server/middleware.go b/api/server/middleware.go index f3e626f813..04944b99d2 100644 --- a/api/server/middleware.go +++ b/api/server/middleware.go @@ -13,6 +13,7 @@ import ( "github.com/docker/docker/api/server/httputils" "github.com/docker/docker/dockerversion" "github.com/docker/docker/errors" + "github.com/docker/docker/pkg/authorization" "github.com/docker/docker/pkg/version" "golang.org/x/net/context" ) @@ -47,6 +48,35 @@ func debugRequestMiddleware(handler httputils.APIFunc) httputils.APIFunc { } } +// authorizationMiddleware perform authorization on the request. +func (s *Server) authorizationMiddleware(handler httputils.APIFunc) httputils.APIFunc { + return func(ctx context.Context, w http.ResponseWriter, r *http.Request, vars map[string]string) error { + // User and UserAuthNMethod are taken from AuthN plugins + // Currently tracked in https://github.com/docker/docker/pull/13994 + user := "" + userAuthNMethod := "" + authCtx := authorization.NewCtx(s.authZPlugins, user, userAuthNMethod, r.Method, r.RequestURI) + + if err := authCtx.AuthZRequest(w, r); err != nil { + logrus.Errorf("AuthZRequest for %s %s returned error: %s", r.Method, r.RequestURI, err) + return err + } + + rw := authorization.NewResponseModifier(w) + + if err := handler(ctx, rw, r, vars); err != nil { + logrus.Errorf("Handler for %s %s returned error: %s", r.Method, r.RequestURI, err) + return err + } + + if err := authCtx.AuthZResponse(rw, r); err != nil { + logrus.Errorf("AuthZResponse for %s %s returned error: %s", r.Method, r.RequestURI, err) + return err + } + return nil + } +} + // userAgentMiddleware checks the User-Agent header looking for a valid docker client spec. func (s *Server) userAgentMiddleware(handler httputils.APIFunc) httputils.APIFunc { return func(ctx context.Context, w http.ResponseWriter, r *http.Request, vars map[string]string) error { @@ -133,6 +163,11 @@ func (s *Server) handleWithGlobalMiddlewares(handler httputils.APIFunc) httputil middlewares = append(middlewares, debugRequestMiddleware) } + if len(s.cfg.AuthZPluginNames) > 0 { + s.authZPlugins = authorization.NewPlugins(s.cfg.AuthZPluginNames) + middlewares = append(middlewares, s.authorizationMiddleware) + } + h := handler for _, m := range middlewares { h = m(h) diff --git a/api/server/server.go b/api/server/server.go index b2857e4e18..e005baf240 100644 --- a/api/server/server.go +++ b/api/server/server.go @@ -16,6 +16,7 @@ import ( "github.com/docker/docker/api/server/router/system" "github.com/docker/docker/api/server/router/volume" "github.com/docker/docker/daemon" + "github.com/docker/docker/pkg/authorization" "github.com/docker/docker/pkg/sockets" "github.com/docker/docker/utils" "github.com/gorilla/mux" @@ -28,13 +29,14 @@ const versionMatcher = "/v{version:[0-9.]+}" // Config provides the configuration for the API server type Config struct { - Logging bool - EnableCors bool - CorsHeaders string - Version string - SocketGroup string - TLSConfig *tls.Config - Addrs []Addr + Logging bool + EnableCors bool + CorsHeaders string + AuthZPluginNames []string + Version string + SocketGroup string + TLSConfig *tls.Config + Addrs []Addr } // Server contains instance details for the server @@ -42,6 +44,7 @@ type Server struct { cfg *Config servers []*HTTPServer routers []router.Router + authZPlugins []authorization.Plugin } // Addr contains string representation of address and its protocol (tcp, unix...). diff --git a/daemon/config.go b/daemon/config.go index 9962ee74ae..83904498db 100644 --- a/daemon/config.go +++ b/daemon/config.go @@ -14,6 +14,7 @@ const ( // CommonConfig defines the configuration of a docker daemon which are // common across platforms. type CommonConfig struct { + AuthZPlugins []string // AuthZPlugins holds list of authorization plugins AutoRestart bool Bridge bridgeConfig // Bridge holds bridge network specific configuration. Context map[string][]string @@ -54,6 +55,7 @@ type CommonConfig struct { // from the command-line. func (config *Config) InstallCommonFlags(cmd *flag.FlagSet, usageFn func(string) string) { cmd.Var(opts.NewListOptsRef(&config.GraphOptions, nil), []string{"-storage-opt"}, usageFn("Set storage driver options")) + cmd.Var(opts.NewListOptsRef(&config.AuthZPlugins, nil), []string{"-authz-plugins"}, usageFn("List of authorization plugins by order of evaluation")) cmd.Var(opts.NewListOptsRef(&config.ExecOptions, nil), []string{"-exec-opt"}, usageFn("Set exec driver options")) cmd.StringVar(&config.Pidfile, []string{"p", "-pidfile"}, defaultPidFile, usageFn("Path to use for daemon PID file")) cmd.StringVar(&config.Root, []string{"g", "-graph"}, defaultGraph, usageFn("Root of the Docker runtime")) diff --git a/docker/daemon.go b/docker/daemon.go index a237d83fe5..72da5c2c03 100644 --- a/docker/daemon.go +++ b/docker/daemon.go @@ -177,8 +177,9 @@ func (cli *DaemonCli) CmdDaemon(args ...string) error { } serverConfig := &apiserver.Config{ - Logging: true, - Version: dockerversion.Version, + AuthZPluginNames: cli.Config.AuthZPlugins, + Logging: true, + Version: dockerversion.Version, } serverConfig = setPlatformServerConfig(serverConfig, cli.Config) diff --git a/docs/extend/authorization.md b/docs/extend/authorization.md index 2f722e6fa2..7b599b28c7 100644 --- a/docs/extend/authorization.md +++ b/docs/extend/authorization.md @@ -91,9 +91,10 @@ Message | string | Authorization message (will be returned to the client in case ### Setting up docker daemon -Authorization plugins are enabled with a dedicated command line argument. The argument contains a comma separated list of the plugin names, which should be the same as the plugin’s socket or spec file. +Authorization plugins are enabled with a dedicated command line argument. The argument contains the plugin name, which should be the same as the plugin’s socket or spec file. +Multiple authz-plugin parameters are supported. ``` -$ docker -d authz-plugins=plugin1,plugin2,... +$ docker daemon --authz-plugins=plugin1 --auth-plugins=plugin2,... ``` ### Calling authorized command (allow) diff --git a/integration-cli/docker_cli_authz_unix_test.go b/integration-cli/docker_cli_authz_unix_test.go new file mode 100644 index 0000000000..72e4e8fa30 --- /dev/null +++ b/integration-cli/docker_cli_authz_unix_test.go @@ -0,0 +1,228 @@ +// +build !windows + +package main + +import ( + "encoding/json" + "fmt" + "github.com/docker/docker/pkg/authorization" + "github.com/docker/docker/pkg/integration/checker" + "github.com/docker/docker/pkg/plugins" + "github.com/go-check/check" + "io/ioutil" + "net/http" + "net/http/httptest" + "os" + "strings" +) + +const testAuthZPlugin = "authzplugin" +const unauthorizedMessage = "User unauthorized authz plugin" +const containerListAPI = "/containers/json" + +func init() { + check.Suite(&DockerAuthzSuite{ + ds: &DockerSuite{}, + }) +} + +type DockerAuthzSuite struct { + server *httptest.Server + ds *DockerSuite + d *Daemon + ctrl *authorizationController +} + +type authorizationController struct { + reqRes authorization.Response // reqRes holds the plugin response to the initial client request + resRes authorization.Response // resRes holds the plugin response to the daemon response + psRequestCnt int // psRequestCnt counts the number of calls to list container request api + psResponseCnt int // psResponseCnt counts the number of calls to list containers response API + requestsURIs []string // requestsURIs stores all request URIs that are sent to the authorization controller + +} + +func (s *DockerAuthzSuite) SetUpTest(c *check.C) { + s.d = NewDaemon(c) + s.ctrl = &authorizationController{} +} + +func (s *DockerAuthzSuite) TearDownTest(c *check.C) { + s.d.Stop() + s.ds.TearDownTest(c) + s.ctrl = nil +} + +func (s *DockerAuthzSuite) SetUpSuite(c *check.C) { + mux := http.NewServeMux() + s.server = httptest.NewServer(mux) + c.Assert(s.server, check.NotNil, check.Commentf("Failed to start a HTTP Server")) + + mux.HandleFunc("/Plugin.Activate", func(w http.ResponseWriter, r *http.Request) { + b, err := json.Marshal(plugins.Manifest{Implements: []string{authorization.AuthZApiImplements}}) + c.Assert(err, check.IsNil) + w.Write(b) + }) + + mux.HandleFunc("/AuthZPlugin.AuthZReq", func(w http.ResponseWriter, r *http.Request) { + b, err := json.Marshal(s.ctrl.reqRes) + w.Write(b) + c.Assert(err, check.IsNil) + defer r.Body.Close() + body, err := ioutil.ReadAll(r.Body) + c.Assert(err, check.IsNil) + authReq := authorization.Request{} + err = json.Unmarshal(body, &authReq) + c.Assert(err, check.IsNil) + + assertBody(c, authReq.RequestURI, authReq.RequestHeaders, authReq.RequestBody) + assertAuthHeaders(c, authReq.RequestHeaders) + + // Count only container list api + if strings.HasSuffix(authReq.RequestURI, containerListAPI) { + s.ctrl.psRequestCnt++ + } + + s.ctrl.requestsURIs = append(s.ctrl.requestsURIs, authReq.RequestURI) + }) + + mux.HandleFunc("/AuthZPlugin.AuthZRes", func(w http.ResponseWriter, r *http.Request) { + b, err := json.Marshal(s.ctrl.resRes) + c.Assert(err, check.IsNil) + w.Write(b) + + defer r.Body.Close() + body, err := ioutil.ReadAll(r.Body) + c.Assert(err, check.IsNil) + authReq := authorization.Request{} + err = json.Unmarshal(body, &authReq) + c.Assert(err, check.IsNil) + + assertBody(c, authReq.RequestURI, authReq.ResponseHeaders, authReq.ResponseBody) + assertAuthHeaders(c, authReq.ResponseHeaders) + + // Count only container list api + if strings.HasSuffix(authReq.RequestURI, containerListAPI) { + s.ctrl.psResponseCnt++ + } + }) + + err := os.MkdirAll("/etc/docker/plugins", 0755) + c.Assert(err, checker.IsNil) + + fileName := fmt.Sprintf("/etc/docker/plugins/%s.spec", testAuthZPlugin) + err = ioutil.WriteFile(fileName, []byte(s.server.URL), 0644) + c.Assert(err, checker.IsNil) +} + +// assertAuthHeaders validates authentication headers are removed +func assertAuthHeaders(c *check.C, headers map[string]string) error { + for k := range headers { + if strings.Contains(strings.ToLower(k), "auth") || strings.Contains(strings.ToLower(k), "x-registry") { + c.Errorf("Found authentication headers in request '%v'", headers) + } + } + return nil +} + +// assertBody asserts that body is removed for non text/json requests +func assertBody(c *check.C, requestURI string, headers map[string]string, body []byte) { + + if strings.Contains(strings.ToLower(requestURI), "auth") && len(body) > 0 { + //return fmt.Errorf("Body included for authentication endpoint %s", string(body)) + c.Errorf("Body included for authentication endpoint %s", string(body)) + } + + for k, v := range headers { + if strings.EqualFold(k, "Content-Type") && strings.HasPrefix(v, "text/") || v == "application/json" { + return + } + } + if len(body) > 0 { + c.Errorf("Body included while it should not (Headers: '%v')", headers) + } +} + +func (s *DockerAuthzSuite) TearDownSuite(c *check.C) { + if s.server == nil { + return + } + + s.server.Close() + + err := os.RemoveAll("/etc/docker/plugins") + c.Assert(err, checker.IsNil) +} + +func (s *DockerAuthzSuite) TestAuthZPluginAllowRequest(c *check.C) { + + err := s.d.Start("--authz-plugins=" + testAuthZPlugin) + c.Assert(err, check.IsNil) + s.ctrl.reqRes.Allow = true + s.ctrl.resRes.Allow = true + + // Ensure command successful + out, err := s.d.Cmd("run", "-d", "--name", "container1", "busybox:latest", "top") + c.Assert(err, check.IsNil) + + // Extract the id of the created container + res := strings.Split(strings.TrimSpace(out), "\n") + id := res[len(res)-1] + assertURIRecorded(c, s.ctrl.requestsURIs, "/containers/create") + assertURIRecorded(c, s.ctrl.requestsURIs, fmt.Sprintf("/containers/%s/start", id)) + + out, err = s.d.Cmd("ps") + c.Assert(err, check.IsNil) + c.Assert(assertContainerList(out, []string{id}), check.Equals, true) + c.Assert(s.ctrl.psRequestCnt, check.Equals, 1) + c.Assert(s.ctrl.psResponseCnt, check.Equals, 1) +} + +func (s *DockerAuthzSuite) TestAuthZPluginDenyRequest(c *check.C) { + + err := s.d.Start("--authz-plugins=" + testAuthZPlugin) + c.Assert(err, check.IsNil) + s.ctrl.reqRes.Allow = false + s.ctrl.reqRes.Msg = unauthorizedMessage + + // Ensure command is blocked + res, err := s.d.Cmd("ps") + c.Assert(err, check.NotNil) + c.Assert(s.ctrl.psRequestCnt, check.Equals, 1) + c.Assert(s.ctrl.psResponseCnt, check.Equals, 0) + + // Ensure unauthorized message appears in response + c.Assert(res, check.Equals, fmt.Sprintf("Error response from daemon: %s\n", unauthorizedMessage)) +} + +func (s *DockerAuthzSuite) TestAuthZPluginDenyResponse(c *check.C) { + + err := s.d.Start("--authz-plugins=" + testAuthZPlugin) + c.Assert(err, check.IsNil) + s.ctrl.reqRes.Allow = true + s.ctrl.resRes.Allow = false + s.ctrl.resRes.Msg = unauthorizedMessage + + // Ensure command is blocked + res, err := s.d.Cmd("ps") + c.Assert(err, check.NotNil) + c.Assert(s.ctrl.psRequestCnt, check.Equals, 1) + c.Assert(s.ctrl.psResponseCnt, check.Equals, 1) + + // Ensure unauthorized message appears in response + c.Assert(res, check.Equals, fmt.Sprintf("Error response from daemon: %s\n", unauthorizedMessage)) +} + +// assertURIRecorded verifies that the given URI was sent and recorded in the authz plugin +func assertURIRecorded(c *check.C, uris []string, uri string) { + + found := false + for _, u := range uris { + if strings.Contains(u, uri) { + found = true + } + } + if !found { + c.Fatalf("Expected to find URI '%s', recorded uris '%s'", uri, strings.Join(uris, ",")) + } +} diff --git a/integration-cli/docker_cli_help_test.go b/integration-cli/docker_cli_help_test.go index 8ec218bda0..0f4a091c58 100644 --- a/integration-cli/docker_cli_help_test.go +++ b/integration-cli/docker_cli_help_test.go @@ -133,7 +133,7 @@ func (s *DockerSuite) TestHelpTextVerify(c *check.C) { // Check each line for lots of stuff lines := strings.Split(out, "\n") for _, line := range lines { - c.Assert(len(line), checker.LessOrEqualThan, 90, check.Commentf("Help for %q is too long:\n%s", cmd, line)) + c.Assert(len(line), checker.LessOrEqualThan, 91, check.Commentf("Help for %q is too long:\n%s", cmd, line)) if scanForHome && strings.Contains(line, `"`+home) { c.Fatalf("Help for %q should use ~ instead of %q on:\n%s", diff --git a/pkg/authorization/api.go b/pkg/authorization/api.go new file mode 100644 index 0000000000..0d931a09c1 --- /dev/null +++ b/pkg/authorization/api.go @@ -0,0 +1,52 @@ +package authorization + +const ( + // AuthZApiRequest is the url for daemon request authorization + AuthZApiRequest = "AuthZPlugin.AuthZReq" + + // AuthZApiResponse is the url for daemon response authorization + AuthZApiResponse = "AuthZPlugin.AuthZRes" + + // AuthZApiImplements is the name of the interface all AuthZ plugins implement + AuthZApiImplements = "authz" +) + +// Request holds data required for authZ plugins +type Request struct { + // User holds the user extracted by AuthN mechanism + User string `json:"User,omitempty"` + + // UserAuthNMethod holds the mechanism used to extract user details (e.g., krb) + UserAuthNMethod string `json:"UserAuthNMethod,omitempty"` + + // RequestMethod holds the HTTP method (GET/POST/PUT) + RequestMethod string `json:"RequestMethod,omitempty"` + + // RequestUri holds the full HTTP uri (e.g., /v1.21/version) + RequestURI string `json:"RequestUri,omitempty"` + + // RequestBody stores the raw request body sent to the docker daemon + RequestBody []byte `json:"RequestBody,omitempty"` + + // RequestHeaders stores the raw request headers sent to the docker daemon + RequestHeaders map[string]string `json:"RequestHeaders,omitempty"` + + // ResponseStatusCode stores the status code returned from docker daemon + ResponseStatusCode int `json:"ResponseStatusCode,omitempty"` + + // ResponseBody stores the raw response body sent from docker daemon + ResponseBody []byte `json:"ResponseBody,omitempty"` + + // ResponseHeaders stores the response headers sent to the docker daemon + ResponseHeaders map[string]string `json:"ResponseHeaders,omitempty"` +} + +// Response represents authZ plugin response +type Response struct { + + // Allow indicating whether the user is allowed or not + Allow bool `json:"Allow"` + + // Msg stores the authorization message + Msg string `json:"Msg,omitempty"` +} diff --git a/pkg/authorization/api.md b/pkg/authorization/api.md new file mode 100644 index 0000000000..4cf16bcdab --- /dev/null +++ b/pkg/authorization/api.md @@ -0,0 +1,83 @@ +# Docker Authorization Plug-in API + +## Introduction + +Docker authorization plug-in infrastructure enables extending the functionality of the Docker daemon with respect to user authorization. The infrastructure enables registering a set of external authorization plug-in. Each plug-in receives information about the user and the request and decides whether to allow or deny the request. Only in case all plug-ins allow accessing the resource the access is granted. + +Each plug-in operates as a separate service, and registers with Docker through general (plug-ins API) [https://blog.docker.com/2015/06/extending-docker-with-plugins/]. No Docker daemon recompilation is required in order to add / remove an authentication plug-in. Each plug-in is notified twice for each operation: 1) before the operation is performed and, 2) before the response is returned to the client. The plug-ins can modify the response that is returned to the client. + +The authorization depends on the authorization effort that takes place in parallel [https://github.com/docker/docker/issues/13697]. + +This is the official issue of the authorization effort: https://github.com/docker/docker/issues/14674 + +(Here)[https://github.com/rhatdan/docker-rbac] you can find an open document that discusses a default RBAC plug-in for Docker. + +## Docker daemon configuration + +In order to add a single authentication plug-in or a set of such, please use the following command line argument: + +``` docker -d authz-plugin=authZPlugin1,authZPlugin2 ``` + +## API + +The skeleton code for a typical plug-in can be found here [ADD LINK]. The plug-in must implement two AP methods: + +1. */AuthzPlugin.AuthZReq* - this is the _authorize request_ method that is called before executing the Docker operation. +1. */AuthzPlugin.AuthZRes* - this is the _authorize response_ method that is called before returning the response to the client. + +#### /AuthzPlugin.AuthZReq + +**Request**: + +``` +{ + "User": "The user identification" + "UserAuthNMethod": "The authentication method used" + "RequestMethod": "The HTTP method" + "RequestUri": "The HTTP request URI" + "RequestBody": "Byte array containing the raw HTTP request body" + "RequestHeader": "Byte array containing the raw HTTP request header as a map[string][]string " + "RequestStatusCode": "Request status code" +} +``` + +**Response**: + +``` +{ + "Allow" : "Determined whether the user is allowed or not" + "Msg": "The authorization message" +} +``` + +#### /AuthzPlugin.AuthZRes + +**Request**: +``` +{ + "User": "The user identification" + "UserAuthNMethod": "The authentication method used" + "RequestMethod": "The HTTP method" + "RequestUri": "The HTTP request URI" + "RequestBody": "Byte array containing the raw HTTP request body" + "RequestHeader": "Byte array containing the raw HTTP request header as a map[string][]string" + "RequestStatusCode": "Request status code" + "ResponseBody": "Byte array containing the raw HTTP response body" + "ResponseHeader": "Byte array containing the raw HTTP response header as a map[string][]string" + "ResponseStatusCode":"Response status code" +} +``` + +**Response**: +``` +{ + "Allow" : "Determined whether the user is allowed or not" + "Msg": "The authorization message" + "ModifiedBody": "Byte array containing a modified body of the raw HTTP body (or nil if no changes required)" + "ModifiedHeader": "Byte array containing a modified header of the HTTP response (or nil if no changes required)" + "ModifiedStatusCode": "int containing the modified version of the status code (or 0 if not change is required)" +} +``` + +The modified response enables the authorization plug-in to manipulate the content of the HTTP response. +In case of more than one plug-in, each subsequent plug-in will received a response (optionally) modified by a previous plug-in. \ No newline at end of file diff --git a/pkg/authorization/authz.go b/pkg/authorization/authz.go new file mode 100644 index 0000000000..daebbcb6e3 --- /dev/null +++ b/pkg/authorization/authz.go @@ -0,0 +1,159 @@ +package authorization + +import ( + "bytes" + "fmt" + "io" + "io/ioutil" + "net/http" + "strings" +) + +// NewCtx creates new authZ context, it is used to store authorization information related to a specific docker +// REST http session +// A context provides two method: +// Authenticate Request: +// Call authZ plugins with current REST request and AuthN response +// Request contains full HTTP packet sent to the docker daemon +// https://docs.docker.com/reference/api/docker_remote_api/ +// +// Authenticate Response: +// Call authZ plugins with full info about current REST request, REST response and AuthN response +// The response from this method may contains content that overrides the daemon response +// This allows authZ plugins to filter privileged content +// +// If multiple authZ plugins are specified, the block/allow decision is based on ANDing all plugin results +// For response manipulation, the response from each plugin is piped between plugins. Plugin execution order +// is determined according to daemon parameters +func NewCtx(authZPlugins []Plugin, user, userAuthNMethod, requestMethod, requestURI string) *Ctx { + return &Ctx{plugins: authZPlugins, user: user, userAuthNMethod: userAuthNMethod, requestMethod: requestMethod, requestURI: requestURI} +} + +// Ctx stores a a single request-response interaction context +type Ctx struct { + user string + userAuthNMethod string + requestMethod string + requestURI string + plugins []Plugin + // authReq stores the cached request object for the current transaction + authReq *Request +} + +// AuthZRequest authorized the request to the docker daemon using authZ plugins +func (a *Ctx) AuthZRequest(w http.ResponseWriter, r *http.Request) (err error) { + + var body []byte + if sendBody(a.requestURI, r.Header) { + var drainedBody io.ReadCloser + drainedBody, r.Body, err = drainBody(r.Body) + if err != nil { + return err + } + body, err = ioutil.ReadAll(drainedBody) + defer drainedBody.Close() + + if err != nil { + return err + } + } + + var h bytes.Buffer + err = r.Header.Write(&h) + + if err != nil { + return err + } + + a.authReq = &Request{ + User: a.user, + UserAuthNMethod: a.userAuthNMethod, + RequestMethod: a.requestMethod, + RequestURI: a.requestURI, + RequestBody: body, + RequestHeaders: headers(r.Header)} + + for _, plugin := range a.plugins { + + authRes, err := plugin.AuthZRequest(a.authReq) + + if err != nil { + return err + } + + if !authRes.Allow { + return fmt.Errorf(authRes.Msg) + } + } + + return nil +} + +// AuthZResponse authorized and manipulates the response from docker daemon using authZ plugins +func (a *Ctx) AuthZResponse(rm ResponseModifier, r *http.Request) error { + + a.authReq.ResponseStatusCode = rm.StatusCode() + a.authReq.ResponseHeaders = headers(rm.Header()) + + if sendBody(a.requestURI, rm.Header()) { + a.authReq.ResponseBody = rm.RawBody() + } + + for _, plugin := range a.plugins { + + authRes, err := plugin.AuthZResponse(a.authReq) + + if err != nil { + return err + } + + if !authRes.Allow { + return fmt.Errorf(authRes.Msg) + } + } + + rm.Flush() + + return nil +} + +// drainBody dump the body, it reads the body data into memory and +// see go sources /go/src/net/http/httputil/dump.go +func drainBody(b io.ReadCloser) (r1, r2 io.ReadCloser, err error) { + var buf bytes.Buffer + if _, err = buf.ReadFrom(b); err != nil { + return nil, nil, err + } + if err = b.Close(); err != nil { + return nil, nil, err + } + return ioutil.NopCloser(&buf), ioutil.NopCloser(bytes.NewReader(buf.Bytes())), nil +} + +// sendBody returns true when request/response body should be sent to AuthZPlugin +func sendBody(url string, header http.Header) bool { + + // Skip body for auth endpoint + if strings.HasSuffix(url, "/auth") { + return false + } + + // body is sent only for text or json messages + v := header.Get("Content-Type") + return strings.HasPrefix(v, "text/") || v == "application/json" +} + +// headers returns flatten version of the http headers excluding authorization +func headers(header http.Header) map[string]string { + v := make(map[string]string, 0) + for k, values := range header { + // Skip authorization headers + if strings.EqualFold(k, "Authorization") || strings.EqualFold(k, "X-Registry-Config") || strings.EqualFold(k, "X-Registry-Auth") { + continue + } + for _, val := range values { + v[k] = val + } + } + return v +} diff --git a/pkg/authorization/authz_test.go b/pkg/authorization/authz_test.go new file mode 100644 index 0000000000..47cf401fd5 --- /dev/null +++ b/pkg/authorization/authz_test.go @@ -0,0 +1,220 @@ +package authorization + +import ( + "encoding/json" + "fmt" + "github.com/docker/docker/pkg/plugins" + "github.com/docker/docker/pkg/tlsconfig" + "github.com/gorilla/mux" + "io/ioutil" + "log" + "net" + "net/http" + "net/http/httptest" + "os" + "path" + "reflect" + "testing" +) + +const pluginAddress = "authzplugin.sock" + +func TestAuthZRequestPlugin(t *testing.T) { + + server := authZPluginTestServer{t: t} + go server.start() + defer server.stop() + + authZPlugin := createTestPlugin(t) + + request := Request{ + User: "user", + RequestBody: []byte("sample body"), + RequestURI: "www.authz.com", + RequestMethod: "GET", + RequestHeaders: map[string]string{"header": "value"}, + } + server.replayResponse = Response{ + Allow: true, + Msg: "Sample message", + } + + actualResponse, err := authZPlugin.AuthZRequest(&request) + + if err != nil { + t.Fatalf("Failed to authorize request %v", err) + } + + if !reflect.DeepEqual(server.replayResponse, *actualResponse) { + t.Fatalf("Response must be equal") + } + if !reflect.DeepEqual(request, server.recordedRequest) { + t.Fatalf("Requests must be equal") + } +} + +func TestAuthZResponsePlugin(t *testing.T) { + + server := authZPluginTestServer{t: t} + go server.start() + defer server.stop() + + authZPlugin := createTestPlugin(t) + + request := Request{ + User: "user", + RequestBody: []byte("sample body"), + } + server.replayResponse = Response{ + Allow: true, + Msg: "Sample message", + } + + actualResponse, err := authZPlugin.AuthZResponse(&request) + + if err != nil { + t.Fatalf("Failed to authorize request %v", err) + } + + if !reflect.DeepEqual(server.replayResponse, *actualResponse) { + t.Fatalf("Response must be equal") + } + if !reflect.DeepEqual(request, server.recordedRequest) { + t.Fatalf("Requests must be equal") + } +} + +func TestResponseModifier(t *testing.T) { + + r := httptest.NewRecorder() + m := NewResponseModifier(r) + m.Header().Set("h1", "v1") + m.Write([]byte("body")) + m.WriteHeader(500) + + m.Flush() + if r.Header().Get("h1") != "v1" { + t.Fatalf("Header value must exists %s", r.Header().Get("h1")) + } + if !reflect.DeepEqual(r.Body.Bytes(), []byte("body")) { + t.Fatalf("Body value must exists %s", r.Body.Bytes()) + } + if r.Code != 500 { + t.Fatalf("Status code must be correct %d", r.Code) + } +} + +func TestResponseModifierOverride(t *testing.T) { + + r := httptest.NewRecorder() + m := NewResponseModifier(r) + m.Header().Set("h1", "v1") + m.Write([]byte("body")) + m.WriteHeader(500) + + overrideHeader := make(http.Header) + overrideHeader.Add("h1", "v2") + overrideHeaderBytes, err := json.Marshal(overrideHeader) + if err != nil { + t.Fatalf("override header failed %v", err) + } + + m.OverrideHeader(overrideHeaderBytes) + m.OverrideBody([]byte("override body")) + m.OverrideStatusCode(404) + m.Flush() + if r.Header().Get("h1") != "v2" { + t.Fatalf("Header value must exists %s", r.Header().Get("h1")) + } + if !reflect.DeepEqual(r.Body.Bytes(), []byte("override body")) { + t.Fatalf("Body value must exists %s", r.Body.Bytes()) + } + if r.Code != 404 { + t.Fatalf("Status code must be correct %d", r.Code) + } +} + +// createTestPlugin creates a new sample authorization plugin +func createTestPlugin(t *testing.T) *authorizationPlugin { + plugin := &plugins.Plugin{Name: "authz"} + var err error + pwd, err := os.Getwd() + if err != nil { + fmt.Println(err) + os.Exit(1) + } + if err != nil { + log.Fatal(err) + } + + plugin.Client, err = plugins.NewClient("unix:///"+path.Join(pwd, pluginAddress), tlsconfig.Options{InsecureSkipVerify: true}) + + if err != nil { + t.Fatalf("Failed to create client %v", err) + } + + return &authorizationPlugin{name: "plugin", plugin: plugin} +} + +// AuthZPluginTestServer is a simple server that implements the authZ plugin interface +type authZPluginTestServer struct { + listener net.Listener + t *testing.T + // request stores the request sent from the daemon to the plugin + recordedRequest Request + // response stores the response sent from the plugin to the daemon + replayResponse Response +} + +// start starts the test server that implements the plugin +func (t *authZPluginTestServer) start() { + r := mux.NewRouter() + os.Remove(pluginAddress) + l, err := net.ListenUnix("unix", &net.UnixAddr{Name: pluginAddress, Net: "unix"}) + if err != nil { + t.t.Fatalf("Failed to listen %v", err) + } + t.listener = l + + r.HandleFunc("/Plugin.Activate", t.activate) + r.HandleFunc("/"+AuthZApiRequest, t.auth) + r.HandleFunc("/"+AuthZApiResponse, t.auth) + t.listener, err = net.Listen("tcp", pluginAddress) + server := http.Server{Handler: r, Addr: pluginAddress} + server.Serve(l) +} + +// stop stops the test server that implements the plugin +func (t *authZPluginTestServer) stop() { + + os.Remove(pluginAddress) + + if t.listener != nil { + t.listener.Close() + } +} + +// auth is a used to record/replay the authentication api messages +func (t *authZPluginTestServer) auth(w http.ResponseWriter, r *http.Request) { + + t.recordedRequest = Request{} + + defer r.Body.Close() + body, err := ioutil.ReadAll(r.Body) + json.Unmarshal(body, &t.recordedRequest) + b, err := json.Marshal(t.replayResponse) + if err != nil { + log.Fatal(err) + } + w.Write(b) + +} + +func (t *authZPluginTestServer) activate(w http.ResponseWriter, r *http.Request) { + + b, err := json.Marshal(plugins.Manifest{Implements: []string{AuthZApiImplements}}) + if err != nil { + log.Fatal(err) + } + w.Write(b) +} diff --git a/pkg/authorization/plugin.go b/pkg/authorization/plugin.go new file mode 100644 index 0000000000..cab571e813 --- /dev/null +++ b/pkg/authorization/plugin.go @@ -0,0 +1,87 @@ +package authorization + +import ( + "github.com/Sirupsen/logrus" + "github.com/docker/docker/pkg/plugins" +) + +// Plugin allows third party plugins to authorize requests and responses +// in the context of docker API +type Plugin interface { + + // AuthZRequest authorize the request from the client to the daemon + AuthZRequest(authReq *Request) (authRes *Response, err error) + + // AuthZResponse authorize the response from the daemon to the client + AuthZResponse(authReq *Request) (authRes *Response, err error) +} + +// NewPlugins constructs and initialize the authorization plugins based on plugin names +func NewPlugins(names []string) []Plugin { + plugins := make([]Plugin, len(names)) + for i, name := range names { + plugins[i] = newAuthorizationPlugin(name) + } + return plugins +} + +// authorizationPlugin is an internal adapter to docker plugin system +type authorizationPlugin struct { + plugin *plugins.Plugin + name string +} + +func newAuthorizationPlugin(name string) Plugin { + return &authorizationPlugin{name: name} +} + +func (a *authorizationPlugin) AuthZRequest(authReq *Request) (authRes *Response, err error) { + + logrus.Debugf("AuthZ requset using plugins %s", a.name) + + err = a.initPlugin() + if err != nil { + return nil, err + } + + authRes = &Response{} + err = a.plugin.Client.Call(AuthZApiRequest, authReq, authRes) + + if err != nil { + return nil, err + } + + return authRes, nil +} + +func (a *authorizationPlugin) AuthZResponse(authReq *Request) (authRes *Response, err error) { + + logrus.Debugf("AuthZ response using plugins %s", a.name) + + err = a.initPlugin() + if err != nil { + return nil, err + } + + authRes = &Response{} + err = a.plugin.Client.Call(AuthZApiResponse, authReq, authRes) + + if err != nil { + return nil, err + } + + return authRes, nil +} + +// initPlugin initialize the authorization plugin if needed +func (a *authorizationPlugin) initPlugin() (err error) { + + // Lazy loading of plugins + if a.plugin == nil { + a.plugin, err = plugins.Get(a.name, AuthZApiImplements) + if err != nil { + return err + } + } + return nil +} diff --git a/pkg/authorization/response.go b/pkg/authorization/response.go new file mode 100644 index 0000000000..ee4cb2db75 --- /dev/null +++ b/pkg/authorization/response.go @@ -0,0 +1,140 @@ +package authorization + +import ( + "bufio" + "bytes" + "encoding/json" + "fmt" + "net" + "net/http" +) + +// ResponseModifier allows authorization plugins to read and modify the content of the http.response +type ResponseModifier interface { + http.ResponseWriter + + // RawBody returns the current http content + RawBody() []byte + + // RawHeaders returns the current content of the http headers + RawHeaders() ([]byte, error) + + // StatusCode returns the current status code + StatusCode() int + + // OverrideBody replace the body of the HTTP reply + OverrideBody(b []byte) + + // OverrideHeader replace the headers of the HTTP reply + OverrideHeader(b []byte) error + + // OverrideStatusCode replaces the status code of the HTTP reply + OverrideStatusCode(statusCode int) + + // Flush flushes all data to the HTTP response + Flush() error +} + +// NewResponseModifier creates a wrapper to an http.ResponseWriter to allow inspecting and modifying the content +func NewResponseModifier(rw http.ResponseWriter) ResponseModifier { + return &responseModifier{rw: rw, header: make(http.Header)} +} + +// responseModifier is used as an adapter to http.ResponseWriter in order to manipulate and explore +// the http request/response from docker daemon +type responseModifier struct { + // The original response writer + rw http.ResponseWriter + status int + // body holds the response body + body []byte + // header holds the response header + header http.Header + // statusCode holds the response status code + statusCode int +} + +// WriterHeader stores the http status code +func (rm *responseModifier) WriteHeader(s int) { + rm.statusCode = s +} + +// Header returns the internal http header +func (rm *responseModifier) Header() http.Header { + return rm.header +} + +// Header returns the internal http header +func (rm *responseModifier) StatusCode() int { + return rm.statusCode +} + +// Override replace the body of the HTTP reply +func (rm *responseModifier) OverrideBody(b []byte) { + rm.body = b +} + +func (rm *responseModifier) OverrideStatusCode(statusCode int) { + rm.statusCode = statusCode +} + +// Override replace the headers of the HTTP reply +func (rm *responseModifier) OverrideHeader(b []byte) error { + header := http.Header{} + err := json.Unmarshal(b, &header) + + if err != nil { + return err + } + rm.header = header + return nil +} + +// Write stores the byte array inside content +func (rm *responseModifier) Write(b []byte) (int, error) { + rm.body = append(rm.body, b...) + return len(b), nil +} + +// Body returns the response body +func (rm *responseModifier) RawBody() []byte { + return rm.body +} + +func (rm *responseModifier) RawHeaders() ([]byte, error) { + var b bytes.Buffer + err := rm.header.Write(&b) + if err != nil { + return nil, err + } + return b.Bytes(), nil +} + +// Hijack returns the internal connection of the wrapped http.ResponseWriter +func (rm *responseModifier) Hijack() (net.Conn, *bufio.ReadWriter, error) { + hijacker, ok := rm.rw.(http.Hijacker) + if !ok { + return nil, nil, fmt.Errorf("Internal reponse writer doesn't support the Hijacker interface") + } + return hijacker.Hijack() +} + +// Flush flushes all data to the HTTP response +func (rm *responseModifier) Flush() error { + + // Copy the status code + if rm.statusCode > 0 { + rm.rw.WriteHeader(rm.statusCode) + } + + // Copy the header + for k, vv := range rm.header { + for _, v := range vv { + rm.rw.Header().Add(k, v) + } + } + + // Write body + _, err := rm.rw.Write(rm.body) + return err +}