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
This commit is contained in:
John-Michael Faircloth 2021-08-18 13:20:27 -05:00 committed by GitHub
parent 9d910a5d71
commit 95979b24d1
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
3 changed files with 393 additions and 0 deletions

View file

@ -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
}

View file

@ -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)

View file

@ -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)
}