From 95979b24d1334cfd2a736bdb877dca531d52f5d3 Mon Sep 17 00:00:00 2001 From: John-Michael Faircloth Date: Wed, 18 Aug 2021 13:20:27 -0500 Subject: [PATCH] feature: OIDC provider scope API (#12266) * initial commit * add read and delete operations * fix bug in delete and add list unit test * func doc typo fix * add existence check for assignment * remove locking on the assignment resource It is not needed at this time. * convert Callbacks to Operations - convert Callbacks to Operations - add test case for update operations * add CRUD operations and test cases * remove use of oidcCache * remove use of oidcCache * add template validation and update tests * refactor struct and var names * harmonize test name conventions * refactor struct and var names * add changelog and refactor - add changelog - be more explicit in the case where we do not recieve a path field * refactor be more explicit in the case where a field is not provided * remove extra period from changelog * update scope path to be OIDC provider specific * update assignment path * update scope path * removed unused name field * removed unused name field * update scope template description * error when attempting to created scope with openid reserved name --- vault/identity_store_oidc_provider.go | 184 ++++++++++++++++++ vault/identity_store_oidc_provider_test.go | 208 +++++++++++++++++++++ vault/identity_store_oidc_test.go | 1 + 3 files changed, 393 insertions(+) diff --git a/vault/identity_store_oidc_provider.go b/vault/identity_store_oidc_provider.go index f49534399d..e2501fcd4d 100644 --- a/vault/identity_store_oidc_provider.go +++ b/vault/identity_store_oidc_provider.go @@ -2,8 +2,13 @@ package vault import ( "context" + "encoding/base64" + "encoding/json" + "strings" + "github.com/hashicorp/go-secure-stdlib/strutil" "github.com/hashicorp/vault/sdk/framework" + "github.com/hashicorp/vault/sdk/helper/identitytpl" "github.com/hashicorp/vault/sdk/logical" ) @@ -12,9 +17,15 @@ type assignment struct { Entities []string `json:"entities"` } +type scope struct { + Template string `json:"template"` + Description string `json:"description"` +} + const ( oidcProviderPrefix = "oidc_provider/" assignmentPath = oidcProviderPrefix + "assignment/" + scopePath = oidcProviderPrefix + "scope/" ) func oidcProviderPaths(i *IdentityStore) []*framework.Path { @@ -63,6 +74,50 @@ func oidcProviderPaths(i *IdentityStore) []*framework.Path { HelpSynopsis: "List OIDC assignments", HelpDescription: "List all configured OIDC assignments in the identity backend.", }, + { + Pattern: "oidc/scope/" + framework.GenericNameRegex("name"), + Fields: map[string]*framework.FieldSchema{ + "name": { + Type: framework.TypeString, + Description: "Name of the scope", + }, + "template": { + Type: framework.TypeString, + Description: "The template string to use for the scope. This may be in string-ified JSON or base64 format.", + }, + "description": { + Type: framework.TypeString, + Description: "The description of the scope", + }, + }, + Operations: map[logical.Operation]framework.OperationHandler{ + logical.UpdateOperation: &framework.PathOperation{ + Callback: i.pathOIDCCreateUpdateScope, + }, + logical.CreateOperation: &framework.PathOperation{ + Callback: i.pathOIDCCreateUpdateScope, + }, + logical.ReadOperation: &framework.PathOperation{ + Callback: i.pathOIDCReadScope, + }, + logical.DeleteOperation: &framework.PathOperation{ + Callback: i.pathOIDCDeleteScope, + }, + }, + ExistenceCheck: i.pathOIDCScopeExistenceCheck, + HelpSynopsis: "CRUD operations for OIDC scopes.", + HelpDescription: "Create, Read, Update, and Delete OIDC scopes.", + }, + { + Pattern: "oidc/scope/?$", + Operations: map[logical.Operation]framework.OperationHandler{ + logical.ListOperation: &framework.PathOperation{ + Callback: i.pathOIDCListScope, + }, + }, + HelpSynopsis: "List OIDC scopes", + HelpDescription: "List all configured OIDC scopes in the identity backend.", + }, } } @@ -161,3 +216,132 @@ func (i *IdentityStore) pathOIDCAssignmentExistenceCheck(ctx context.Context, re return entry != nil, nil } + +// pathOIDCCreateUpdateScope is used to create a new scope or update an existing one +func (i *IdentityStore) pathOIDCCreateUpdateScope(ctx context.Context, req *logical.Request, d *framework.FieldData) (*logical.Response, error) { + name := d.Get("name").(string) + if name == "openid" { + return logical.ErrorResponse("the \"openid\" scope name is reserved"), nil + } + + var scope scope + if req.Operation == logical.UpdateOperation { + entry, err := req.Storage.Get(ctx, scopePath+name) + if err != nil { + return nil, err + } + if entry != nil { + if err := entry.DecodeJSON(&scope); err != nil { + return nil, err + } + } + } + + if descriptionRaw, ok := d.GetOk("description"); ok { + scope.Description = descriptionRaw.(string) + } else if req.Operation == logical.CreateOperation { + scope.Description = d.GetDefaultOrZero("description").(string) + } + + if templateRaw, ok := d.GetOk("template"); ok { + scope.Template = templateRaw.(string) + } else if req.Operation == logical.CreateOperation { + scope.Template = d.GetDefaultOrZero("template").(string) + } + + // Attempt to decode as base64 and use that if it works + if decoded, err := base64.StdEncoding.DecodeString(scope.Template); err == nil { + scope.Template = string(decoded) + } + + // Validate that template can be parsed and results in valid JSON + if scope.Template != "" { + _, populatedTemplate, err := identitytpl.PopulateString(identitytpl.PopulateStringInput{ + Mode: identitytpl.JSONTemplating, + String: scope.Template, + Entity: new(logical.Entity), + Groups: make([]*logical.Group, 0), + // namespace? + }) + if err != nil { + return logical.ErrorResponse("error parsing template: %s", err.Error()), nil + } + + var tmp map[string]interface{} + if err := json.Unmarshal([]byte(populatedTemplate), &tmp); err != nil { + return logical.ErrorResponse("error parsing template JSON: %s", err.Error()), nil + } + + for key := range tmp { + if strutil.StrListContains(requiredClaims, key) { + return logical.ErrorResponse(`top level key %q not allowed. Restricted keys: %s`, + key, strings.Join(requiredClaims, ", ")), nil + } + } + } + // store scope + entry, err := logical.StorageEntryJSON(scopePath+name, scope) + if err != nil { + return nil, err + } + + if err := req.Storage.Put(ctx, entry); err != nil { + return nil, err + } + + return nil, nil +} + +// pathOIDCListScope is used to list scopes +func (i *IdentityStore) pathOIDCListScope(ctx context.Context, req *logical.Request, d *framework.FieldData) (*logical.Response, error) { + scopes, err := req.Storage.List(ctx, scopePath) + if err != nil { + return nil, err + } + return logical.ListResponse(scopes), nil +} + +// pathOIDCReadScope is used to read an existing scope +func (i *IdentityStore) pathOIDCReadScope(ctx context.Context, req *logical.Request, d *framework.FieldData) (*logical.Response, error) { + name := d.Get("name").(string) + + entry, err := req.Storage.Get(ctx, scopePath+name) + if err != nil { + return nil, err + } + if entry == nil { + return nil, nil + } + + var scope scope + if err := entry.DecodeJSON(&scope); err != nil { + return nil, err + } + return &logical.Response{ + Data: map[string]interface{}{ + "template": scope.Template, + "description": scope.Description, + }, + }, nil +} + +// pathOIDCDeleteScope is used to delete an scope +func (i *IdentityStore) pathOIDCDeleteScope(ctx context.Context, req *logical.Request, d *framework.FieldData) (*logical.Response, error) { + name := d.Get("name").(string) + err := req.Storage.Delete(ctx, scopePath+name) + if err != nil { + return nil, err + } + return nil, nil +} + +func (i *IdentityStore) pathOIDCScopeExistenceCheck(ctx context.Context, req *logical.Request, d *framework.FieldData) (bool, error) { + name := d.Get("name").(string) + + entry, err := req.Storage.Get(ctx, scopePath+name) + if err != nil { + return false, err + } + + return entry != nil, nil +} diff --git a/vault/identity_store_oidc_provider_test.go b/vault/identity_store_oidc_provider_test.go index 1148306369..c171d30c76 100644 --- a/vault/identity_store_oidc_provider_test.go +++ b/vault/identity_store_oidc_provider_test.go @@ -8,6 +8,214 @@ import ( "github.com/hashicorp/vault/sdk/logical" ) +// TestOIDC_Path_OIDC_ProviderScope_ReservedName tests that the reserved name +// "openid" cannot be used when creating a scope +func TestOIDC_Path_OIDC_ProviderScope_ReservedName(t *testing.T) { + c, _, _ := TestCoreUnsealed(t) + ctx := namespace.RootContext(nil) + storage := &logical.InmemStorage{} + + // Create a test scope "test-scope" -- should succeed + resp, err := c.identityStore.HandleRequest(ctx, &logical.Request{ + Path: "oidc/scope/openid", + Operation: logical.CreateOperation, + Storage: storage, + }) + expectError(t, resp, err) + // validate error message + expectedStrings := map[string]interface{}{ + "the \"openid\" scope name is reserved": true, + } + expectStrings(t, []string{resp.Data["error"].(string)}, expectedStrings) +} + +// TestOIDC_Path_OIDC_ProviderScope tests CRUD operations for scopes +func TestOIDC_Path_OIDC_ProviderScope(t *testing.T) { + c, _, _ := TestCoreUnsealed(t) + ctx := namespace.RootContext(nil) + storage := &logical.InmemStorage{} + + // Create a test scope "test-scope" -- should succeed + resp, err := c.identityStore.HandleRequest(ctx, &logical.Request{ + Path: "oidc/scope/test-scope", + Operation: logical.CreateOperation, + Storage: storage, + }) + expectSuccess(t, resp, err) + + // Read "test-scope" and validate + resp, err = c.identityStore.HandleRequest(ctx, &logical.Request{ + Path: "oidc/scope/test-scope", + Operation: logical.ReadOperation, + Storage: storage, + }) + expectSuccess(t, resp, err) + expected := map[string]interface{}{ + "template": "", + "description": "", + } + if diff := deep.Equal(expected, resp.Data); diff != nil { + t.Fatal(diff) + } + + // Update "test-scope" -- should succeed + resp, err = c.identityStore.HandleRequest(ctx, &logical.Request{ + Path: "oidc/scope/test-scope", + Operation: logical.UpdateOperation, + Data: map[string]interface{}{ + "template": "eyAiZ3JvdXBzIjoge3tpZGVudGl0eS5lbnRpdHkuZ3JvdXBzLm5hbWVzfX0gfQ==", + "description": "my-description", + }, + Storage: storage, + }) + expectSuccess(t, resp, err) + + // Read "test-scope" again and validate + resp, err = c.identityStore.HandleRequest(ctx, &logical.Request{ + Path: "oidc/scope/test-scope", + Operation: logical.ReadOperation, + Storage: storage, + }) + expectSuccess(t, resp, err) + expected = map[string]interface{}{ + "template": "{ \"groups\": {{identity.entity.groups.names}} }", + "description": "my-description", + } + if diff := deep.Equal(expected, resp.Data); diff != nil { + t.Fatal(diff) + } + + // Delete test-scope -- should succeed + resp, err = c.identityStore.HandleRequest(ctx, &logical.Request{ + Path: "oidc/scope/test-scope", + Operation: logical.DeleteOperation, + Storage: storage, + }) + expectSuccess(t, resp, err) + + // Read "test-scope" again and validate + resp, _ = c.identityStore.HandleRequest(ctx, &logical.Request{ + Path: "oidc/scope/test-scope", + Operation: logical.ReadOperation, + Storage: storage, + }) + if resp != nil { + t.Fatalf("expected nil but got resp: %#v", resp) + } +} + +// TestOIDC_Path_OIDC_ProviderScope_Update tests Update operations for scopes +func TestOIDC_Path_OIDC_ProviderScope_Update(t *testing.T) { + c, _, _ := TestCoreUnsealed(t) + ctx := namespace.RootContext(nil) + storage := &logical.InmemStorage{} + + // Create a test scope "test-scope" -- should succeed + resp, err := c.identityStore.HandleRequest(ctx, &logical.Request{ + Path: "oidc/scope/test-scope", + Operation: logical.CreateOperation, + Storage: storage, + Data: map[string]interface{}{ + "template": "eyAiZ3JvdXBzIjoge3tpZGVudGl0eS5lbnRpdHkuZ3JvdXBzLm5hbWVzfX0gfQ==", + "description": "my-description", + }, + }) + expectSuccess(t, resp, err) + + // Read "test-scope" and validate + resp, err = c.identityStore.HandleRequest(ctx, &logical.Request{ + Path: "oidc/scope/test-scope", + Operation: logical.ReadOperation, + Storage: storage, + }) + expectSuccess(t, resp, err) + expected := map[string]interface{}{ + "template": "{ \"groups\": {{identity.entity.groups.names}} }", + "description": "my-description", + } + if diff := deep.Equal(expected, resp.Data); diff != nil { + t.Fatal(diff) + } + + // Update "test-scope" -- should succeed + resp, err = c.identityStore.HandleRequest(ctx, &logical.Request{ + Path: "oidc/scope/test-scope", + Operation: logical.UpdateOperation, + Data: map[string]interface{}{ + "template": "eyAiZ3JvdXBzIjoge3tpZGVudGl0eS5lbnRpdHkuZ3JvdXBzLm5hbWVzfX0gfQ==", + "description": "my-description-2", + }, + Storage: storage, + }) + expectSuccess(t, resp, err) + + // Read "test-scope" again and validate + resp, err = c.identityStore.HandleRequest(ctx, &logical.Request{ + Path: "oidc/scope/test-scope", + Operation: logical.ReadOperation, + Storage: storage, + }) + expectSuccess(t, resp, err) + expected = map[string]interface{}{ + "template": "{ \"groups\": {{identity.entity.groups.names}} }", + "description": "my-description-2", + } + if diff := deep.Equal(expected, resp.Data); diff != nil { + t.Fatal(diff) + } +} + +// TestOIDC_Path_OIDC_ProviderScope_List tests the List operation for scopes +func TestOIDC_Path_OIDC_ProviderScope_List(t *testing.T) { + c, _, _ := TestCoreUnsealed(t) + ctx := namespace.RootContext(nil) + storage := &logical.InmemStorage{} + + // Prepare two scopes, test-scope1 and test-scope2 + c.identityStore.HandleRequest(ctx, &logical.Request{ + Path: "oidc/scope/test-scope1", + Operation: logical.CreateOperation, + Storage: storage, + }) + + c.identityStore.HandleRequest(ctx, &logical.Request{ + Path: "oidc/scope/test-scope2", + Operation: logical.CreateOperation, + Storage: storage, + }) + + // list scopes + respListScopes, listErr := c.identityStore.HandleRequest(ctx, &logical.Request{ + Path: "oidc/scope", + Operation: logical.ListOperation, + Storage: storage, + }) + expectSuccess(t, respListScopes, listErr) + + // validate list response + expectedStrings := map[string]interface{}{"test-scope1": true, "test-scope2": true} + expectStrings(t, respListScopes.Data["keys"].([]string), expectedStrings) + + // delete test-scope2 + c.identityStore.HandleRequest(ctx, &logical.Request{ + Path: "oidc/scope/test-scope2", + Operation: logical.DeleteOperation, + Storage: storage, + }) + + // list scopes again and validate response + respListScopeAfterDelete, listErrAfterDelete := c.identityStore.HandleRequest(ctx, &logical.Request{ + Path: "oidc/scope", + Operation: logical.ListOperation, + Storage: storage, + }) + expectSuccess(t, respListScopeAfterDelete, listErrAfterDelete) + + // validate list response + delete(expectedStrings, "test-scope2") + expectStrings(t, respListScopeAfterDelete.Data["keys"].([]string), expectedStrings) +} + // TestOIDC_Path_OIDC_ProviderAssignment tests CRUD operations for assignments func TestOIDC_Path_OIDC_ProviderAssignment(t *testing.T) { c, _, _ := TestCoreUnsealed(t) diff --git a/vault/identity_store_oidc_test.go b/vault/identity_store_oidc_test.go index 2f039e7ff0..09cbd2cdf5 100644 --- a/vault/identity_store_oidc_test.go +++ b/vault/identity_store_oidc_test.go @@ -1231,6 +1231,7 @@ func expectError(t *testing.T, resp *logical.Response, err error) { // expectString fails unless every string in actualStrings is also included in expectedStrings and // the length of actualStrings and expectedStrings are the same func expectStrings(t *testing.T, actualStrings []string, expectedStrings map[string]interface{}) { + t.Helper() if len(actualStrings) != len(expectedStrings) { t.Fatalf("expectStrings mismatch:\nactual strings:\n%#v\nexpected strings:\n%#v\n", actualStrings, expectedStrings) }