diff --git a/builtin/credential/app-id/backend.go b/builtin/credential/app-id/backend.go new file mode 100644 index 0000000000..ee0dd7267c --- /dev/null +++ b/builtin/credential/app-id/backend.go @@ -0,0 +1,97 @@ +package appId + +import ( + "github.com/hashicorp/vault/logical" + "github.com/hashicorp/vault/logical/framework" +) + +func Factory(map[string]string) (logical.Backend, error) { + return Backend(), nil +} + +func Backend() *framework.Backend { + var b backend + b.MapAppId = &framework.PolicyMap{ + PathMap: &framework.PathMap{"app-id"}, + DefaultKey: "default", + } + b.MapUserId = &framework.PathMap{ + Name: "user-id", + } + + b.Backend = &framework.Backend{ + Help: backendHelp, + + PathsSpecial: &logical.Paths{ + Unauthenticated: []string{ + "login", + }, + }, + + Paths: framework.PathAppend([]*framework.Path{ + pathLogin(&b), + }, + b.MapAppId.Paths(), + b.MapUserId.Paths(), + ), + } + + return b.Backend +} + +type backend struct { + *framework.Backend + + MapAppId *framework.PolicyMap + MapUserId *framework.PathMap +} + +const backendHelp = ` +The App ID credential provider is used to perform authentication from +within applications or machine by pairing together two hard-to-guess +unique pieces of information: a unique app ID, and a unique user ID. + +The goal of this credential provider is to allow elastic users +(dynamic machines, containers, etc.) to authenticate with Vault without +having to store passwords outside of Vault. It is a single method of +solving the chicken-and-egg problem of setting up Vault access on a machine. +With this provider, nobody except the machine itself has access to both +pieces of information necessary to authenticate. For example: +configuration management will have the app IDs, but the machine itself +will detect its user ID based on some unique machine property such as a +MAC address (or a hash of it with some salt). + +An example, real world process for using this provider: + + 1. Create unique app IDs (UUIDs work well) and map them to policies. + (Path: map/app-id/) + + 2. Store the app IDs within configuration management systems. + + 3. An out-of-band process run by security operators map unique user IDs + to these app IDs. Example: when an instance is launched, a cloud-init + system tells security operators a unique ID for this machine. This + process can be scripted, but the key is that it is out-of-band and + out of reach of configuration management. + (Path: map/user-id/) + + 4. A new server is provisioned. Configuration management configures the + app ID, the server itself detects its user ID. With both of these + pieces of information, Vault can be accessed according to the policy + set by the app ID. + +More details on this process follow: + +The app ID is a unique ID that maps to a set of policies. This ID is +generated by an operator and configured into the backend. The ID itself +is usually a UUID, but any hard-to-guess unique value can be used. + +After creating app IDs, an operator authorizes a fixed set of user IDs +with each app ID. When the valid {app ID, user ID} set is tuple is given +to the "login" path, then the user is authenticated with the configured +app ID policies. + +The user ID can be any value (just like the app ID), however it is +generally a value unique to a machine, such as a MAC address or instance ID, +or a value hashed from these unique values. +` diff --git a/builtin/credential/app-id/backend_test.go b/builtin/credential/app-id/backend_test.go new file mode 100644 index 0000000000..9f3a9c22db --- /dev/null +++ b/builtin/credential/app-id/backend_test.go @@ -0,0 +1,69 @@ +package appId + +import ( + "testing" + + "github.com/hashicorp/vault/logical" + logicaltest "github.com/hashicorp/vault/logical/testing" +) + +func TestBackend_basic(t *testing.T) { + logicaltest.Test(t, logicaltest.TestCase{ + Backend: Backend(), + Steps: []logicaltest.TestStep{ + testAccStepMapAppId(t), + testAccStepMapUserId(t), + testAccLogin(t), + testAccLoginInvalid(t), + }, + }) +} + +func testAccStepMapAppId(t *testing.T) logicaltest.TestStep { + return logicaltest.TestStep{ + Operation: logical.WriteOperation, + Path: "map/app-id/foo", + Data: map[string]interface{}{ + "value": "foo,bar", + }, + } +} + +func testAccStepMapUserId(t *testing.T) logicaltest.TestStep { + return logicaltest.TestStep{ + Operation: logical.WriteOperation, + Path: "map/user-id/42", + Data: map[string]interface{}{ + "value": "foo", + }, + } +} + +func testAccLogin(t *testing.T) logicaltest.TestStep { + return logicaltest.TestStep{ + Operation: logical.WriteOperation, + Path: "login", + Data: map[string]interface{}{ + "app_id": "foo", + "user_id": "42", + }, + Unauthenticated: true, + + Check: logicaltest.TestCheckAuth([]string{"bar", "foo"}), + } +} + +func testAccLoginInvalid(t *testing.T) logicaltest.TestStep { + return logicaltest.TestStep{ + Operation: logical.WriteOperation, + Path: "login", + Data: map[string]interface{}{ + "app_id": "foo", + "user_id": "48", + }, + ErrorOk: true, + Unauthenticated: true, + + Check: logicaltest.TestCheckError(), + } +} diff --git a/builtin/credential/app-id/path_login.go b/builtin/credential/app-id/path_login.go new file mode 100644 index 0000000000..2774316e84 --- /dev/null +++ b/builtin/credential/app-id/path_login.go @@ -0,0 +1,64 @@ +package appId + +import ( + "strings" + + "github.com/hashicorp/vault/logical" + "github.com/hashicorp/vault/logical/framework" +) + +func pathLogin(b *backend) *framework.Path { + return &framework.Path{ + Pattern: "login", + Fields: map[string]*framework.FieldSchema{ + "app_id": &framework.FieldSchema{ + Type: framework.TypeString, + Description: "The unique app ID", + }, + + "user_id": &framework.FieldSchema{ + Type: framework.TypeString, + Description: "The unique user ID", + }, + }, + + Callbacks: map[logical.Operation]framework.OperationFunc{ + logical.WriteOperation: b.pathLogin, + }, + } +} + +func (b *backend) pathLogin( + req *logical.Request, data *framework.FieldData) (*logical.Response, error) { + appId := data.Get("app_id").(string) + userId := data.Get("user_id").(string) + + // Look up the apps that this user is allowed to access + apps, err := b.MapUserId.Get(req.Storage, userId) + if err != nil { + return nil, err + } + + // Verify that the app is in the list + found := false + for _, app := range strings.Split(apps, ",") { + if strings.TrimSpace(app) == appId { + found = true + } + } + if !found { + return logical.ErrorResponse("invalid user ID or app ID"), nil + } + + // Get the policies associated with the app + policies, err := b.MapAppId.Policies(req.Storage, appId) + if err != nil { + return nil, err + } + + return &logical.Response{ + Auth: &logical.Auth{ + Policies: policies, + }, + }, nil +} diff --git a/commands.go b/commands.go index d5d9cde684..b52029c71d 100644 --- a/commands.go +++ b/commands.go @@ -5,7 +5,8 @@ import ( auditFile "github.com/hashicorp/vault/builtin/audit/file" - "github.com/hashicorp/vault/builtin/credential/github" + credAppId "github.com/hashicorp/vault/builtin/credential/app-id" + credGitHub "github.com/hashicorp/vault/builtin/credential/github" "github.com/hashicorp/vault/builtin/logical/aws" "github.com/hashicorp/vault/builtin/logical/consul" @@ -115,7 +116,8 @@ func init() { "file": auditFile.Factory, }, CredentialBackends: map[string]logical.Factory{ - "github": github.Factory, + "app-id": credAppId.Factory, + "github": credGitHub.Factory, }, LogicalBackends: map[string]logical.Factory{ "aws": aws.Factory, diff --git a/logical/framework/path.go b/logical/framework/path.go index ce9b341979..92f14384ba 100644 --- a/logical/framework/path.go +++ b/logical/framework/path.go @@ -8,6 +8,17 @@ import ( "github.com/hashicorp/vault/logical" ) +// PathAppend is a helper for appending lists of paths into a single +// list. +func PathAppend(paths ...[]*Path) []*Path { + result := make([]*Path, 0, 10) + for _, ps := range paths { + result = append(result, ps...) + } + + return result +} + // Path is a single path that the backend responds to. type Path struct { // Pattern is the pattern of the URL that matches this path. diff --git a/logical/testing/testing.go b/logical/testing/testing.go index 8a90b854c3..04f1fc13e4 100644 --- a/logical/testing/testing.go +++ b/logical/testing/testing.go @@ -55,6 +55,9 @@ type TestStep struct { // step will be called Check TestCheckFunc + // ErrorOk, if true, will let erroneous responses through to the check + ErrorOk bool + // Unauthenticated, if true, will make the request unauthenticated. Unauthenticated bool } @@ -172,7 +175,7 @@ func Test(t TestT, c TestCase) { resp.Data, )) } - if err == nil && resp.IsError() { + if err == nil && resp.IsError() && !s.ErrorOk { err = fmt.Errorf("Erroneous response:\n\n%#v", resp) } if err == nil && s.Check != nil { @@ -246,6 +249,17 @@ func TestCheckAuth(policies []string) TestCheckFunc { } } +// TestCheckError is a helper to check that a response is an error. +func TestCheckError() TestCheckFunc { + return func(resp *logical.Response) error { + if !resp.IsError() { + return fmt.Errorf("response should be error") + } + + return nil + } +} + // TestT is the interface used to handle the test lifecycle of a test. // // Users should just use a *testing.T object, which implements this.