feat: enforce token tag restrictions at the server resolution chokepoints
This commit is contained in:
@@ -71,6 +71,25 @@ func GetServer(instanceID, serverID string) (*models.Server, error) {
|
||||
return &s, nil
|
||||
}
|
||||
|
||||
// GetServerScoped is GetServer narrowed by the acting credential's tag
|
||||
// restriction. An out-of-scope server reads as not-found, never as forbidden:
|
||||
// a restricted token must not be able to enumerate the fleet it cannot see by
|
||||
// noticing which IDs answer differently.
|
||||
//
|
||||
// mongo.ErrNoDocuments is GetServer's own not-found identifier — reused here
|
||||
// rather than introducing a second one, so a caller checking for one keeps
|
||||
// working against a server that exists but is out of the token's scope.
|
||||
func GetServerScoped(instanceID, serverID string, tokenScope map[string]string) (*models.Server, error) {
|
||||
srv, err := GetServer(instanceID, serverID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !ServerInTokenScope(*srv, tokenScope) {
|
||||
return nil, mongo.ErrNoDocuments
|
||||
}
|
||||
return srv, nil
|
||||
}
|
||||
|
||||
func getServerByID(serverID string) (*models.Server, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
@@ -66,3 +66,33 @@ func ResolveTargets(instanceID string, ids []string, sel map[string]string) ([]m
|
||||
}
|
||||
return matched, nil
|
||||
}
|
||||
|
||||
// ResolveTargetsScoped is ResolveTargets narrowed by the acting credential's
|
||||
// tag restriction.
|
||||
//
|
||||
// This is the chokepoint that matters: workflow runs, console connections and
|
||||
// update application all resolve targets through here, so filtering once here
|
||||
// covers the mutating surface rather than each handler remembering.
|
||||
//
|
||||
// A request naming an out-of-scope server by ID resolves to nothing rather than
|
||||
// to an error, which is what makes an out-of-scope host indistinguishable from
|
||||
// one that does not exist.
|
||||
func ResolveTargetsScoped(instanceID string, ids []string, sel, tokenScope map[string]string) ([]models.Server, error) {
|
||||
all, err := ListServers(instanceID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
visible := make([]models.Server, 0, len(all))
|
||||
for _, s := range all {
|
||||
if ServerInTokenScope(s, tokenScope) {
|
||||
visible = append(visible, s)
|
||||
}
|
||||
}
|
||||
|
||||
matched := UnionTargets(visible, ids, sel)
|
||||
if len(matched) == 0 {
|
||||
return nil, ErrNoTargets
|
||||
}
|
||||
return matched, nil
|
||||
}
|
||||
|
||||
@@ -88,3 +88,26 @@ func TestSelectorNarrowerOrEqual(t *testing.T) {
|
||||
t.Error("restricted child of an unrestricted parent rejected")
|
||||
}
|
||||
}
|
||||
|
||||
// UnionTargets is the pure core of target resolution, so scoping can be proved
|
||||
// without a database by filtering its input the way ResolveTargetsScoped does.
|
||||
func TestScopedTargetsExcludeOutOfScopeServers(t *testing.T) {
|
||||
all := []models.Server{
|
||||
{ServerID: "a", Tags: map[string]string{"env": "staging"}},
|
||||
{ServerID: "b", Tags: map[string]string{"env": "prod"}},
|
||||
}
|
||||
|
||||
scope := map[string]string{"env": "staging"}
|
||||
visible := []models.Server{}
|
||||
for _, s := range all {
|
||||
if ServerInTokenScope(s, scope) {
|
||||
visible = append(visible, s)
|
||||
}
|
||||
}
|
||||
|
||||
// Naming an out-of-scope server by ID must not reach it.
|
||||
got := UnionTargets(visible, []string{"a", "b"}, nil)
|
||||
if len(got) != 1 || got[0].ServerID != "a" {
|
||||
t.Errorf("scoped targets = %v, want only a", got)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user