Harden API-key model: per-key scoping + revocable keys, and configurable/whitelist-aware rate limiting (#16)
* API scope and rate limiting * improvements from review
This commit is contained in:
1 parent
a400e07ae2
commit
495edffde3
11 files changed
+843
-42
No files matched your search
@@ -73,32 +73,55 @@ func (c *Client) Initialize() error {
|
||||
}
|
||||
|
||||
// SecretFilter limits lookup by vault placement. Empty fields are ignored (no constraint).
|
||||
// Use at most one of organization id vs name, etc., enforced at the HTTP layer.
|
||||
//
|
||||
// The singular fields are client-supplied query filters (use at most one of id vs
|
||||
// name per dimension, enforced at the HTTP layer). The plural fields are server-set
|
||||
// from the authenticated key's scope and act as a hard boundary: a request must match
|
||||
// both the client filter and the scope, so a client filter can only narrow within the
|
||||
// scope, never widen beyond it. Empty plural slices impose no constraint.
|
||||
type SecretFilter struct {
|
||||
OrganizationID string
|
||||
CollectionID string
|
||||
FolderID string
|
||||
|
||||
OrganizationIDs []string
|
||||
CollectionIDs []string
|
||||
}
|
||||
|
||||
func containsFold(ids []string, target string) bool {
|
||||
for _, id := range ids {
|
||||
if strings.EqualFold(id, target) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func intersectsFold(a, b []string) bool {
|
||||
for _, id := range a {
|
||||
if containsFold(b, id) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func matchesSecretFilter(item DecryptedItem, f SecretFilter) bool {
|
||||
if f.OrganizationID != "" && !strings.EqualFold(item.OrganizationID, f.OrganizationID) {
|
||||
return false
|
||||
}
|
||||
if f.CollectionID != "" {
|
||||
found := false
|
||||
for _, id := range item.CollectionIDs {
|
||||
if strings.EqualFold(id, f.CollectionID) {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return false
|
||||
}
|
||||
if f.CollectionID != "" && !containsFold(item.CollectionIDs, f.CollectionID) {
|
||||
return false
|
||||
}
|
||||
if f.FolderID != "" && !strings.EqualFold(item.FolderID, f.FolderID) {
|
||||
return false
|
||||
}
|
||||
if len(f.OrganizationIDs) > 0 && !containsFold(f.OrganizationIDs, item.OrganizationID) {
|
||||
return false
|
||||
}
|
||||
if len(f.CollectionIDs) > 0 && !intersectsFold(item.CollectionIDs, f.CollectionIDs) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
@@ -55,7 +55,7 @@ func TestMatchesSecretFilter(t *testing.T) {
|
||||
SecretFilter{
|
||||
OrganizationID: testOrgID,
|
||||
CollectionID: testCollectionID,
|
||||
FolderID: testFolderID,
|
||||
FolderID: testFolderID,
|
||||
},
|
||||
true,
|
||||
},
|
||||
@@ -64,6 +64,35 @@ func TestMatchesSecretFilter(t *testing.T) {
|
||||
SecretFilter{OrganizationID: testOrgID, CollectionID: "77777777-7777-4777-8777-777777777777"},
|
||||
false,
|
||||
},
|
||||
// Server-side scope (plural fields).
|
||||
{"scope org in set", SecretFilter{OrganizationIDs: []string{testOrgID2, testOrgID}}, true},
|
||||
{"scope org not in set", SecretFilter{OrganizationIDs: []string{testOrgID2}}, false},
|
||||
{"scope collection intersects", SecretFilter{CollectionIDs: []string{testCollectionID}}, true},
|
||||
{
|
||||
"scope collection disjoint",
|
||||
SecretFilter{CollectionIDs: []string{"77777777-7777-4777-8777-777777777777"}},
|
||||
false,
|
||||
},
|
||||
{
|
||||
"scope org and collection both match",
|
||||
SecretFilter{OrganizationIDs: []string{testOrgID}, CollectionIDs: []string{testCollectionID}},
|
||||
true,
|
||||
},
|
||||
{
|
||||
"scope org ok but collection disjoint",
|
||||
SecretFilter{OrganizationIDs: []string{testOrgID}, CollectionIDs: []string{"77777777-7777-4777-8777-777777777777"}},
|
||||
false,
|
||||
},
|
||||
{
|
||||
"scope narrowed further by client filter",
|
||||
SecretFilter{OrganizationID: testOrgID, OrganizationIDs: []string{testOrgID}},
|
||||
true,
|
||||
},
|
||||
{
|
||||
"client filter cannot widen beyond scope",
|
||||
SecretFilter{OrganizationID: testOrgID, OrganizationIDs: []string{testOrgID2}},
|
||||
false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
@@ -75,3 +104,17 @@ func TestMatchesSecretFilter(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchesSecretFilter_PersonalItemExcludedByOrgScope(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// A personal (no-org) item must be excluded once a key scopes to organizations.
|
||||
personal := DecryptedItem{ID: "personal-1", Name: "personal-secret"}
|
||||
|
||||
if matchesSecretFilter(personal, SecretFilter{OrganizationIDs: []string{testOrgID}}) {
|
||||
t.Error("personal item should be excluded by an org scope")
|
||||
}
|
||||
if !matchesSecretFilter(personal, SecretFilter{}) {
|
||||
t.Error("personal item should match an empty (full-access) scope")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user