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
38 changes: 38 additions & 0 deletions framework/configstore/migrations.go
Original file line number Diff line number Diff line change
Expand Up @@ -746,6 +746,9 @@ func triggerMigrations(ctx context.Context, db *gorm.DB) error {
if err := migrationDropLegacyCalendarAlignedColumns(ctx, db); err != nil {
return err
}
if err := migrationAddVKAccessProfileIDColumn(ctx, db); err != nil {
return err
}
return nil
}

Expand Down Expand Up @@ -7661,3 +7664,38 @@ func migrationAddTeamCalendarAlignedColumn(ctx context.Context, db *gorm.DB) err
}
return nil
}

// migrationAddVKAccessProfileIDColumn adds access_profile_id to governance_virtual_keys
// so that existing VKs can be attached directly to an access profile template (enterprise feature).
func migrationAddVKAccessProfileIDColumn(ctx context.Context, db *gorm.DB) error {
m := migrator.New(db, migrator.DefaultOptions, []*migrator.Migration{{
ID: "add_vk_access_profile_id_column",
Migrate: func(tx *gorm.DB) error {
tx = tx.WithContext(ctx)
mig := tx.Migrator()
if !mig.HasColumn(&tables.TableVirtualKey{}, "access_profile_id") {
if err := mig.AddColumn(&tables.TableVirtualKey{}, "AccessProfileID"); err != nil {
return fmt.Errorf("failed to add access_profile_id column to governance_virtual_keys: %w", err)
}
}
if !mig.HasIndex(&tables.TableVirtualKey{}, "idx_governance_virtual_keys_access_profile_id") {
if err := mig.CreateIndex(&tables.TableVirtualKey{}, "AccessProfileID"); err != nil {
return fmt.Errorf("failed to create index on governance_virtual_keys.access_profile_id: %w", err)
}
}
return nil
},
Rollback: func(tx *gorm.DB) error {
tx = tx.WithContext(ctx)
mig := tx.Migrator()
if mig.HasColumn(&tables.TableVirtualKey{}, "access_profile_id") {
return mig.DropColumn(&tables.TableVirtualKey{}, "access_profile_id")
}
return nil
},
}})
if err := m.Migrate(); err != nil {
return fmt.Errorf("error running add_vk_access_profile_id_column migration: %s", err.Error())
}
return nil
}
2 changes: 2 additions & 0 deletions framework/configstore/rdb.go
Original file line number Diff line number Diff line change
Expand Up @@ -2281,6 +2281,8 @@ func (s *RDBConfigStore) GetVirtualKeysPaginated(ctx context.Context, params Vir
baseQuery = baseQuery.Where("customer_id = ?", params.CustomerID)
} else if params.TeamID != "" {
baseQuery = baseQuery.Where("team_id = ?", params.TeamID)
} else if params.AccessProfileID > 0 {
baseQuery = baseQuery.Where("access_profile_id = ?", params.AccessProfileID)
Comment thread
BearTS marked this conversation as resolved.
}
if params.Search != "" {
search := "%" + strings.ToLower(params.Search) + "%"
Expand Down
1 change: 1 addition & 0 deletions framework/configstore/store.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ type VirtualKeyQueryParams struct {
Search string
CustomerID string
TeamID string
AccessProfileID uint // When set, return VKs attached to this access profile template
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
30 changes: 20 additions & 10 deletions framework/configstore/tables/virtualkey.go
Original file line number Diff line number Diff line change
Expand Up @@ -205,10 +205,11 @@ type TableVirtualKey struct {
ProviderConfigs []TableVirtualKeyProviderConfig `gorm:"foreignKey:VirtualKeyID;constraint:OnDelete:CASCADE" json:"provider_configs"` // Empty means no providers allowed (deny-by-default)
MCPConfigs []TableVirtualKeyMCPConfig `gorm:"foreignKey:VirtualKeyID;constraint:OnDelete:CASCADE" json:"mcp_configs"`

// Foreign key relationships (mutually exclusive: either TeamID or CustomerID, not both)
TeamID *string `gorm:"type:varchar(255);index" json:"team_id,omitempty"`
CustomerID *string `gorm:"type:varchar(255);index" json:"customer_id,omitempty"`
RateLimitID *string `gorm:"type:varchar(255);index" json:"rate_limit_id,omitempty"`
// Foreign key relationships (mutually exclusive: TeamID, CustomerID, or AccessProfileID)
TeamID *string `gorm:"type:varchar(255);index" json:"team_id,omitempty"`
CustomerID *string `gorm:"type:varchar(255);index" json:"customer_id,omitempty"`
AccessProfileID *uint `gorm:"index" json:"access_profile_id,omitempty"`
Comment thread
coderabbitai[bot] marked this conversation as resolved.
RateLimitID *string `gorm:"type:varchar(255);index" json:"rate_limit_id,omitempty"`
Comment thread
coderabbitai[bot] marked this conversation as resolved.

CalendarAligned bool `gorm:"default:false" json:"calendar_aligned"`

Expand Down Expand Up @@ -243,13 +244,22 @@ func (vk *TableVirtualKey) IsActiveValue() bool {
return *vk.IsActive
}

// BeforeSave is a GORM hook that enforces mutual exclusion (team vs customer), computes
// a SHA-256 hash of the plaintext value for indexed lookups, and encrypts the virtual key
// value before writing to the database.
// BeforeSave is a GORM hook that enforces mutual exclusion among team, customer, and
// access profile attachments, computes a SHA-256 hash of the plaintext value for indexed
// lookups, and encrypts the virtual key value before writing to the database.
func (vk *TableVirtualKey) BeforeSave(tx *gorm.DB) error {
// Enforce mutual exclusion: VK can belong to either Team OR Customer, not both
if vk.TeamID != nil && vk.CustomerID != nil {
return fmt.Errorf("virtual key cannot belong to both team and customer")
entityCount := 0
if vk.TeamID != nil {
entityCount++
}
if vk.CustomerID != nil {
entityCount++
}
if vk.AccessProfileID != nil && *vk.AccessProfileID > 0 {
entityCount++
}
if entityCount > 1 {
return fmt.Errorf("virtual key can only be attached to one of: team, customer, or access profile")
}

// Hash must be computed before encryption (from plaintext value)
Expand Down
82 changes: 65 additions & 17 deletions transports/bifrost-http/handlers/governance.go
Original file line number Diff line number Diff line change
Expand Up @@ -91,9 +91,10 @@ type CreateVirtualKeyRequest struct {
MCPClientName string `json:"mcp_client_name" validate:"required"`
ToolsToExecute schemas.WhiteList `json:"tools_to_execute,omitempty"`
} `json:"mcp_configs,omitempty"` // Empty means no MCP clients allowed (deny-by-default)
TeamID *string `json:"team_id,omitempty"` // Mutually exclusive with CustomerID
CustomerID *string `json:"customer_id,omitempty"` // Mutually exclusive with TeamID
Budgets []CreateBudgetRequest `json:"budgets,omitempty"` // Multi-budget: each must have a unique reset_duration
TeamID *string `json:"team_id,omitempty"` // Mutually exclusive with CustomerID and AccessProfileID
CustomerID *string `json:"customer_id,omitempty"` // Mutually exclusive with TeamID and AccessProfileID
AccessProfileID *uint `json:"access_profile_id,omitempty"` // Mutually exclusive with TeamID and CustomerID
Budgets []CreateBudgetRequest `json:"budgets,omitempty"` // Multi-budget: each must have a unique reset_duration
Comment thread
BearTS marked this conversation as resolved.
RateLimit *CreateRateLimitRequest `json:"rate_limit,omitempty"`
IsActive *bool `json:"is_active,omitempty"`
CalendarAligned bool `json:"calendar_aligned,omitempty"` // When true, all budgets reset at clean calendar boundaries
Expand All @@ -119,6 +120,7 @@ type UpdateVirtualKeyRequest struct {
} `json:"mcp_configs,omitempty"`
TeamID *string `json:"team_id,omitempty"`
CustomerID *string `json:"customer_id,omitempty"`
AccessProfileID *uint `json:"access_profile_id,omitempty"`
Budgets []CreateBudgetRequest `json:"budgets,omitempty"` // Multi-budget: replaces all VK-level budgets
RateLimit *UpdateRateLimitRequest `json:"rate_limit,omitempty"`
IsActive *bool `json:"is_active,omitempty"`
Expand Down Expand Up @@ -372,12 +374,13 @@ 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"))
accessProfileIDStr := string(ctx.QueryArgs().Peek("access_profile_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"

if limitStr != "" || offsetStr != "" || search != "" || customerID != "" || teamID != "" || sortBy != "" || isExport || excludeAccessProfileManagedVirtual {
if limitStr != "" || offsetStr != "" || search != "" || customerID != "" || teamID != "" || accessProfileIDStr != "" || sortBy != "" || isExport || excludeAccessProfileManagedVirtual {
// Paginated/filtered path
params := configstore.VirtualKeyQueryParams{
Search: search,
Expand All @@ -388,6 +391,18 @@ func (h *GovernanceHandler) getVirtualKeys(ctx *fasthttp.RequestCtx) {
Export: isExport,
ExcludeAccessProfileManagedVirtual: excludeAccessProfileManagedVirtual,
}
if accessProfileIDStr != "" {
apID, err := strconv.ParseUint(accessProfileIDStr, 10, 0)
if err != nil {
SendError(ctx, 400, "Invalid access_profile_id parameter: must be a number")
return
}
if (customerID != "" || teamID != "") && apID > 0 {
SendError(ctx, 400, "access_profile_id cannot be combined with team_id or customer_id filters")
return
}
params.AccessProfileID = uint(apID)
}
if limitStr != "" {
n, err := strconv.Atoi(limitStr)
if err != nil {
Expand Down Expand Up @@ -462,9 +477,19 @@ func (h *GovernanceHandler) createVirtualKey(ctx *fasthttp.RequestCtx) {
SendError(ctx, 400, "Virtual key name is required")
return
}
// Validate mutually exclusive TeamID and CustomerID
if req.TeamID != nil && req.CustomerID != nil {
SendError(ctx, 400, "VirtualKey cannot be attached to both Team and Customer")
// Validate mutually exclusive TeamID, CustomerID, and AccessProfileID
entityCount := 0
if req.TeamID != nil {
entityCount++
}
if req.CustomerID != nil {
entityCount++
}
if req.AccessProfileID != nil && *req.AccessProfileID > 0 {
entityCount++
}
if entityCount > 1 {
SendError(ctx, 400, "VirtualKey can only be attached to one of: Team, Customer, or AccessProfile")
return
}
// Validate budgets if provided
Expand Down Expand Up @@ -510,6 +535,7 @@ func (h *GovernanceHandler) createVirtualKey(ctx *fasthttp.RequestCtx) {
Description: req.Description,
TeamID: req.TeamID,
CustomerID: req.CustomerID,
AccessProfileID: req.AccessProfileID,
IsActive: isActive,
CalendarAligned: req.CalendarAligned,
}
Expand Down Expand Up @@ -737,9 +763,19 @@ func (h *GovernanceHandler) updateVirtualKey(ctx *fasthttp.RequestCtx) {
SendError(ctx, 400, "Invalid JSON")
return
}
// Validate mutually exclusive TeamID and CustomerID
if req.TeamID != nil && req.CustomerID != nil {
SendError(ctx, 400, "VirtualKey cannot be attached to both Team and Customer")
// Validate mutually exclusive TeamID, CustomerID, and AccessProfileID
entityCount := 0
if req.TeamID != nil {
entityCount++
}
if req.CustomerID != nil {
entityCount++
}
if req.AccessProfileID != nil && *req.AccessProfileID > 0 {
entityCount++
}
if entityCount > 1 {
SendError(ctx, 400, "VirtualKey can only be attached to one of: Team, Customer, or AccessProfile")
return
}
vk, err := h.configStore.GetVirtualKey(ctx, vkID)
Expand Down Expand Up @@ -791,16 +827,21 @@ func (h *GovernanceHandler) updateVirtualKey(ctx *fasthttp.RequestCtx) {
}
if req.TeamID != nil {
vk.TeamID = req.TeamID
vk.CustomerID = nil // Clear CustomerID if setting TeamID
}
if req.CustomerID != nil {
vk.CustomerID = nil
vk.AccessProfileID = nil
} else if req.CustomerID != nil {
vk.CustomerID = req.CustomerID
vk.TeamID = nil // Clear TeamID if setting CustomerID
}
// When both TeamID and CustomerID are nil
if req.TeamID == nil && req.CustomerID == nil {
vk.TeamID = nil
vk.AccessProfileID = nil
} else if req.AccessProfileID != nil {
vk.AccessProfileID = req.AccessProfileID
vk.TeamID = nil
vk.CustomerID = nil
} else {
// All nil — clear entity assignment
vk.TeamID = nil
vk.CustomerID = nil
vk.AccessProfileID = nil
}
Comment thread
BearTS marked this conversation as resolved.
if req.IsActive != nil {
vk.IsActive = req.IsActive
Expand Down Expand Up @@ -935,6 +976,13 @@ func (h *GovernanceHandler) updateVirtualKey(ctx *fasthttp.RequestCtx) {
if err := h.configStore.UpdateVirtualKey(ctx, vk, tx); err != nil {
return err
}
// UpdateVirtualKey's Select list excludes access_profile_id to protect config-sync paths.
// Persist it separately so API-driven entity assignment is always saved.
if err := tx.Model(&configstoreTables.TableVirtualKey{}).
Where("id = ?", vk.ID).
Updates(map[string]interface{}{"access_profile_id": vk.AccessProfileID}).Error; err != nil {
return fmt.Errorf("failed to update virtual key access_profile_id: %w", err)
}
Comment thread
greptile-apps[bot] marked this conversation as resolved.
if req.ProviderConfigs != nil {
// Get existing provider configs for comparison
var existingConfigs []configstoreTables.TableVirtualKeyProviderConfig
Expand Down
19 changes: 17 additions & 2 deletions ui/app/_fallbacks/enterprise/lib/store/apis/accessProfileApi.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,22 @@
import { GetUserAccessProfilesResponse } from "@enterprise/lib/types/accessProfile";
import { GetAccessProfilesParams, GetAccessProfilesResponse, GetUserAccessProfilesResponse } from "@enterprise/lib/types/accessProfile";

// OSS build has no access-profile backend — return undefined data so consumers
// (e.g. useVirtualKeyUsage) fall back to VK-owned budget/rate-limit values.
// fall back gracefully (empty lists, no AP-specific UI rendered).
export const useGetAccessProfilesQuery = (
_params?: GetAccessProfilesParams | void,
_opts?: { skip?: boolean },
): {
data: GetAccessProfilesResponse | undefined;
isLoading: boolean;
isError: boolean;
error: null;
} => ({
data: undefined,
isLoading: false,
isError: false,
error: null,
});

export const useGetUserAccessProfilesQuery = (
_userId: string,
_opts?: { skip?: boolean; pollingInterval?: number },
Expand Down
16 changes: 16 additions & 0 deletions ui/app/_fallbacks/enterprise/lib/types/accessProfile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,4 +38,20 @@ export interface UserAccessProfile {

export interface GetUserAccessProfilesResponse {
access_profiles: UserAccessProfile[];
}

export interface GetAccessProfilesParams {
limit?: number;
offset?: number;
search?: string;
tags?: string;
is_active?: boolean;
}

export interface GetAccessProfilesResponse {
access_profiles: { id: number; name: string; [key: string]: unknown }[];
count: number;
total_count: number;
limit: number;
offset: number;
}
Loading
Loading