Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 57 additions & 1 deletion docs/openapi/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -44572,7 +44572,7 @@
{
"name": "from_memory",
"in": "query",
"description": "If true, returns virtual keys from in-memory cache instead of database",
"description": "If true, returns virtual keys from in-memory cache instead of database. Filtering, sorting and pagination parameters are not applied in this mode; combining it with user_id is rejected with a 400.",
"schema": {
"type": "boolean",
"default": false
Expand Down Expand Up @@ -44620,6 +44620,14 @@
"type": "string"
}
},
{
"name": "user_id",
"in": "query",
"description": "Filter virtual keys by assigned user ID (enterprise only; matches no virtual keys on OSS builds). Combined with customer_id/team_id using OR.",
"schema": {
"type": "string"
}
},
{
"name": "sort_by",
"in": "query",
Expand Down Expand Up @@ -44681,6 +44689,16 @@
}
}
},
"400": {
"description": "Bad request",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/BifrostError"
}
}
}
},
"500": {
"description": "Internal server error",
"content": {
Expand Down Expand Up @@ -59555,6 +59573,25 @@
"schema": {
"type": "string"
}
},
{
"name": "limit",
"in": "query",
"description": "Maximum number of ranked rows to return. Defaults to 100. Ignored when\n`all=true`.\n",
"schema": {
"type": "integer",
"minimum": 1,
"default": 100
}
},
{
"name": "all",
"in": "query",
"description": "When true, returns every ranked entity with no row cap. Intended for\nexports (CSV / PDF), which must not be truncated.\n",
"schema": {
"type": "boolean",
"default": false
}
}
],
"security": [
Expand Down Expand Up @@ -59915,6 +59952,25 @@
"schema": {
"type": "string"
}
},
{
"name": "limit",
"in": "query",
"description": "Maximum number of ranked rows to return. Defaults to 100. Ignored when\n`all=true`.\n",
"schema": {
"type": "integer",
"minimum": 1,
"default": 100
}
},
{
"name": "all",
"in": "query",
"description": "When true, returns every ranked entity with no row cap. Intended for\nexports (CSV / PDF), which must not be truncated.\n",
"schema": {
"type": "boolean",
"default": false
}
}
],
"security": [
Expand Down
14 changes: 13 additions & 1 deletion docs/openapi/paths/management/governance.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,10 @@ virtual-keys:
parameters:
- name: from_memory
in: query
description: If true, returns virtual keys from in-memory cache instead of database
description: >-
If true, returns virtual keys from in-memory cache instead of database.
Filtering, sorting and pagination parameters are not applied in this mode;
combining it with user_id is rejected with a 400.
schema:
type: boolean
default: false
Expand Down Expand Up @@ -41,6 +44,13 @@ virtual-keys:
description: Filter virtual keys by team ID
schema:
type: string
- name: user_id
in: query
description: >-
Filter virtual keys by assigned user ID (enterprise only; matches no
virtual keys on OSS builds). Combined with customer_id/team_id using OR.
schema:
type: string
- name: sort_by
in: query
description: Field to sort by
Expand Down Expand Up @@ -74,6 +84,8 @@ virtual-keys:
application/json:
schema:
$ref: '../../schemas/management/governance.yaml#/ListVirtualKeysResponse'
'400':
$ref: '../../openapi.yaml#/components/responses/BadRequest'
'500':
$ref: '../../openapi.yaml#/components/responses/InternalError'

Expand Down
28 changes: 20 additions & 8 deletions framework/configstore/rdb.go
Original file line number Diff line number Diff line change
Expand Up @@ -3272,14 +3272,26 @@ func (s *RDBConfigStore) GetVirtualKeysPaginated(ctx context.Context, params Vir
// on what the caller is allowed to see.
baseQuery := s.ScopedDB(ctx).Model(&tables.TableVirtualKey{})

// Virtual keys are either customer-scoped or team-scoped, never both.
// When both filters are provided, use OR to match keys belonging to either.
if params.CustomerID != "" && params.TeamID != "" {
baseQuery = baseQuery.Where("(customer_id = ? OR team_id = ?)", params.CustomerID, params.TeamID)
} else if params.CustomerID != "" {
baseQuery = baseQuery.Where("customer_id = ?", params.CustomerID)
} else if params.TeamID != "" {
baseQuery = baseQuery.Where("team_id = ?", params.TeamID)
// A virtual key is assigned to at most one of customer / team / user, so
// combining assignment filters ORs them rather than narrowing to nothing.
// UserID has no meaning in the OSS build (the VK↔user link lives in an
// enterprise table), so it fails closed instead of silently widening the
// result set; the enterprise store overrides this method to honour it.
var assignmentClauses []string
var assignmentArgs []interface{}
if params.CustomerID != "" {
assignmentClauses = append(assignmentClauses, "customer_id = ?")
assignmentArgs = append(assignmentArgs, params.CustomerID)
}
if params.TeamID != "" {
assignmentClauses = append(assignmentClauses, "team_id = ?")
assignmentArgs = append(assignmentArgs, params.TeamID)
}
if params.UserID != "" {
assignmentClauses = append(assignmentClauses, "1 = 0")
}
if len(assignmentClauses) > 0 {
baseQuery = baseQuery.Where("("+strings.Join(assignmentClauses, " OR ")+")", assignmentArgs...)
Comment thread
impoiler marked this conversation as resolved.
}
if params.Search != "" {
search := "%" + strings.ToLower(params.Search) + "%"
Expand Down
108 changes: 108 additions & 0 deletions framework/configstore/rdb_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"fmt"
"sort"
"strings"
"sync"
"testing"
Expand Down Expand Up @@ -996,6 +997,113 @@ func TestUpdateRateLimit(t *testing.T) {
// Virtual Key Tests
// =============================================================================

// TestGetVirtualKeysPaginated_AssignmentFilters covers how the customer / team /
// user filters compose. A virtual key is assigned to at most one of the three, so
// supplying several ORs them rather than narrowing to nothing.
//
// UserID is unresolvable in the OSS build — the VK↔user link lives in an
// enterprise-only table — so it contributes a never-true disjunct here: a
// user-only filter matches nothing instead of silently returning every key. It
// deliberately does NOT suppress a customer/team disjunct supplied alongside it;
// those are still resolvable, and dropping them would diverge from the enterprise
// store, which returns exactly that union.
func TestGetVirtualKeysPaginated_AssignmentFilters(t *testing.T) {
store := setupRDBTestStore(t)
ctx := context.Background()

require.NoError(t, store.CreateCustomer(ctx, &tables.TableCustomer{ID: "cust-1", Name: "Customer One"}))
require.NoError(t, store.CreateTeam(ctx, &tables.TableTeam{ID: "team-1", Name: "Team One"}))

custID, teamID := "cust-1", "team-1"
seed := []*tables.TableVirtualKey{
{ID: "vk-cust", Name: "Customer Key", Value: *schemas.NewSecretVar("vk-cust-val"), IsActive: schemas.Ptr(true), CustomerID: &custID},
{ID: "vk-team", Name: "Team Key", Value: *schemas.NewSecretVar("vk-team-val"), IsActive: schemas.Ptr(true), TeamID: &teamID},
{ID: "vk-none", Name: "Unassigned Key", Value: *schemas.NewSecretVar("vk-none-val"), IsActive: schemas.Ptr(true)},
}
for _, vk := range seed {
require.NoError(t, store.CreateVirtualKey(ctx, vk))
}

tests := []struct {
name string
params VirtualKeyQueryParams
wantIDs []string
}{
{
name: "no filters returns every key",
params: VirtualKeyQueryParams{},
wantIDs: []string{"vk-cust", "vk-none", "vk-team"},
},
{
name: "customer only",
params: VirtualKeyQueryParams{CustomerID: "cust-1"},
wantIDs: []string{"vk-cust"},
},
{
name: "team only",
params: VirtualKeyQueryParams{TeamID: "team-1"},
wantIDs: []string{"vk-team"},
},
{
name: "customer or team",
params: VirtualKeyQueryParams{CustomerID: "cust-1", TeamID: "team-1"},
wantIDs: []string{"vk-cust", "vk-team"},
},
{
// Fail closed: OSS cannot resolve the assignment, so it matches nothing
// rather than falling through to an unfiltered list.
name: "user only matches nothing in OSS",
params: VirtualKeyQueryParams{UserID: "user-1"},
wantIDs: nil,
},
{
name: "user plus customer keeps the resolvable customer disjunct",
params: VirtualKeyQueryParams{UserID: "user-1", CustomerID: "cust-1"},
wantIDs: []string{"vk-cust"},
},
{
name: "user plus team keeps the resolvable team disjunct",
params: VirtualKeyQueryParams{UserID: "user-1", TeamID: "team-1"},
wantIDs: []string{"vk-team"},
},
{
name: "user plus customer and team keeps both resolvable disjuncts",
params: VirtualKeyQueryParams{UserID: "user-1", CustomerID: "cust-1", TeamID: "team-1"},
wantIDs: []string{"vk-cust", "vk-team"},
},
{
name: "user filter never widens a non-matching search",
params: VirtualKeyQueryParams{UserID: "user-1", Search: "Unassigned"},
wantIDs: nil,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
vks, totalCount, err := store.GetVirtualKeysPaginated(ctx, tt.params)
require.NoError(t, err)

gotIDs := make([]string, 0, len(vks))
for _, vk := range vks {
gotIDs = append(gotIDs, vk.ID)
}
sort.Strings(gotIDs)
assert.Equal(t, tt.wantIDs, nonEmptyIDs(gotIDs))
// The count drives pagination, so it must agree with the page contents.
assert.Equal(t, int64(len(tt.wantIDs)), totalCount)
})
}
}

// nonEmptyIDs normalizes an empty slice to nil so table cases can express
// "matches nothing" as a nil wantIDs.
func nonEmptyIDs(ids []string) []string {
if len(ids) == 0 {
return nil
}
return ids
}

func TestCreateVirtualKey(t *testing.T) {
store := setupRDBTestStore(t)
ctx := context.Background()
Expand Down
1 change: 1 addition & 0 deletions framework/configstore/store.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ type VirtualKeyQueryParams struct {
Search string
CustomerID string
TeamID string
UserID string // Enterprise-only: filters to VKs assigned to this user; matches nothing in OSS
SortBy string // name, budget_spent, created_at, status (default: created_at)
Order string // asc, desc (default: asc)
Export bool // When true, skip default pagination limits (caller controls limit)
Expand Down
14 changes: 13 additions & 1 deletion transports/bifrost-http/handlers/governance.go
Original file line number Diff line number Diff line change
Expand Up @@ -1152,6 +1152,16 @@ func (h *GovernanceHandler) getVirtualKeys(ctx *fasthttp.RequestCtx) {
// Check if "from_memory" query parameter is set to true
fromMemory := string(ctx.QueryArgs().Peek("from_memory")) == "true"
if fromMemory {
// The in-memory cache holds no VK↔user assignments (GovernanceData.Users carries
// only budget/rate-limit ids), so a user_id filter cannot be applied here. Reject
// the combination rather than silently dropping the filter: the database path
// fails closed on user_id, and returning every cached key instead would be the
// exact inverse of that contract. The other filters keep their long-standing
// ignored-under-from_memory behaviour.
if len(ctx.QueryArgs().Peek("user_id")) > 0 {
SendError(ctx, 400, "user_id filter is not supported with from_memory=true; omit from_memory to filter virtual keys by user")
return
}
data := h.governanceManager.GetGovernanceData(ctx)
if data == nil {
SendError(ctx, 500, "Governance data is not available")
Expand Down Expand Up @@ -1190,19 +1200,21 @@ func (h *GovernanceHandler) getVirtualKeys(ctx *fasthttp.RequestCtx) {
search := string(ctx.QueryArgs().Peek("search"))
customerID := string(ctx.QueryArgs().Peek("customer_id"))
teamID := string(ctx.QueryArgs().Peek("team_id"))
userID := string(ctx.QueryArgs().Peek("user_id"))
sortBy := string(ctx.QueryArgs().Peek("sort_by"))
order := string(ctx.QueryArgs().Peek("order"))
isExport := string(ctx.QueryArgs().Peek("export")) == "true"
excludeAccessProfileManagedVirtual := string(ctx.QueryArgs().Peek("exclude_access_profile_managed_virtual")) == "true"
excludeAssignedVirtualKeys := string(ctx.QueryArgs().Peek("exclude_assigned_virtual_keys")) == "true"
forUserAssignment := string(ctx.QueryArgs().Peek("for_user_assignment")) == "true"

if limitStr != "" || offsetStr != "" || search != "" || customerID != "" || teamID != "" || sortBy != "" || isExport || excludeAccessProfileManagedVirtual || excludeAssignedVirtualKeys || forUserAssignment {
if limitStr != "" || offsetStr != "" || search != "" || customerID != "" || teamID != "" || userID != "" || sortBy != "" || isExport || excludeAccessProfileManagedVirtual || excludeAssignedVirtualKeys || forUserAssignment {
// Paginated/filtered path
params := configstore.VirtualKeyQueryParams{
Search: search,
CustomerID: customerID,
TeamID: teamID,
UserID: userID,
Comment thread
coderabbitai[bot] marked this conversation as resolved.
SortBy: sortBy,
Order: order,
Export: isExport,
Expand Down
Loading
Loading