mirror of
https://github.com/hashicorp/vault.git
synced 2026-02-18 18:38:08 -05:00
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:
parent
9d910a5d71
commit
95979b24d1
3 changed files with 393 additions and 0 deletions
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue