fix(access): verify subscription readiness with real user keys

When subscription access is requested with an explicit access_api_key, assign the subscription to the real target user, bind that user's API key to the subscription group, and probe readiness with the same key instead of falling back to a managed synthetic user.

Update the runtime/reconcile flows, adapter tests, and source-of-truth docs so subscription_ready now reflects user-visible host access rather than managed-key-only closure success.
This commit is contained in:
phamnazage-jpg
2026-06-01 09:55:11 +08:00
parent 702ae19a61
commit c588a95c7d
11 changed files with 260 additions and 24 deletions

View File

@@ -78,12 +78,12 @@ func TestServiceCloseAssignsSubscriptionsAndProbesGateway(t *testing.T) {
}
}
func TestServiceCloseSubscriptionManagedKeyOverridesExplicitProbeAPIKey(t *testing.T) {
func TestServiceCloseSubscriptionExplicitProbeAPIKeyUsesRequestedUserKey(t *testing.T) {
host := &fakeClosureHost{
gatewayResult: sub2api.GatewayAccessResult{OK: true, StatusCode: 200, HasExpectedModel: true, Models: []string{"deepseek-chat"}},
completionResult: sub2api.GatewayCompletionResult{OK: true, StatusCode: 200},
managedAccess: map[string]sub2api.SubscriptionAccessRef{
"user-1": {UserID: "host-user-1", APIKey: "managed-user-key"},
"user-1": {UserID: "user-1", APIKey: "caller-supplied-key"},
},
}
service := NewService(host)
@@ -97,14 +97,20 @@ func TestServiceCloseSubscriptionManagedKeyOverridesExplicitProbeAPIKey(t *testi
if err != nil {
t.Fatalf("Close() error = %v", err)
}
if host.gatewayProbe.APIKey != "managed-user-key" {
t.Fatalf("gateway probe api key = %q, want managed-user-key override", host.gatewayProbe.APIKey)
if len(host.assigned) != 1 || host.assigned[0].UserID != "user-1" {
t.Fatalf("assigned subscriptions = %+v, want requested user assignment", host.assigned)
}
if result.EffectiveProbeAPIKey != "managed-user-key" {
t.Fatalf("effective probe api key = %q, want managed-user-key", result.EffectiveProbeAPIKey)
if host.gatewayProbe.APIKey != "caller-supplied-key" {
t.Fatalf("gateway probe api key = %q, want caller-supplied-key", host.gatewayProbe.APIKey)
}
if result.EffectiveProbeKeySource != ProbeKeySourceManagedSubscription {
t.Fatalf("effective probe key source = %q, want %q", result.EffectiveProbeKeySource, ProbeKeySourceManagedSubscription)
if len(host.ensureRequests) != 1 || host.ensureRequests[0].ProbeAPIKey != "caller-supplied-key" {
t.Fatalf("ensure requests = %+v, want probe api key forwarded", host.ensureRequests)
}
if result.EffectiveProbeAPIKey != "caller-supplied-key" {
t.Fatalf("effective probe api key = %q, want caller-supplied-key", result.EffectiveProbeAPIKey)
}
if result.EffectiveProbeKeySource != ProbeKeySourceRequestedProbeAPIKey {
t.Fatalf("effective probe key source = %q, want %q", result.EffectiveProbeKeySource, ProbeKeySourceRequestedProbeAPIKey)
}
}
@@ -197,6 +203,7 @@ func TestServiceCloseRepairsOpenAIResponsesCapabilityMismatch(t *testing.T) {
type fakeClosureHost struct {
assigned []sub2api.AssignSubscriptionRequest
ensureRequests []sub2api.EnsureSubscriptionAccessRequest
managedAccess map[string]sub2api.SubscriptionAccessRef
assignErr error
gatewayProbe sub2api.GatewayAccessCheckRequest
@@ -215,6 +222,7 @@ type fakeClosureHost struct {
}
func (f *fakeClosureHost) EnsureSubscriptionAccess(_ context.Context, req sub2api.EnsureSubscriptionAccessRequest) (sub2api.SubscriptionAccessRef, error) {
f.ensureRequests = append(f.ensureRequests, req)
if ref, ok := f.managedAccess[req.UserSelector]; ok {
return ref, nil
}

View File

@@ -9,8 +9,31 @@ import (
)
func (s *Service) prepareSubscriptionPlan(ctx context.Context, req ClosureRequest, plan closurePlan) (closurePlan, error) {
requestedProbeAPIKey := strings.TrimSpace(req.ProbeAPIKey)
for _, target := range req.Subscriptions {
resolvedTarget := target.UserID
if requestedProbeAPIKey != "" {
if _, err := s.host.AssignSubscription(ctx, sub2api.AssignSubscriptionRequest{
UserID: resolvedTarget,
GroupID: req.GroupID,
DurationDays: target.DurationDays,
}); err != nil {
return closurePlan{}, fmt.Errorf("assign subscription for %s: %w", target.UserID, err)
}
accessRef, err := s.host.EnsureSubscriptionAccess(ctx, sub2api.EnsureSubscriptionAccessRequest{
UserSelector: target.UserID,
GroupID: req.GroupID,
ProbeAPIKey: requestedProbeAPIKey,
})
if err != nil {
return closurePlan{}, fmt.Errorf("ensure subscription access for %s: %w", target.UserID, err)
}
if strings.TrimSpace(accessRef.APIKey) != "" {
plan.effectiveProbeAPIKey = strings.TrimSpace(accessRef.APIKey)
}
continue
}
accessRef, err := s.host.EnsureSubscriptionAccess(ctx, sub2api.EnsureSubscriptionAccessRequest{
UserSelector: target.UserID,
GroupID: req.GroupID,

View File

@@ -216,6 +216,29 @@ func (r batchImportRuntimeRunner) resolveValidationAPIKey(ctx context.Context, i
if err != nil {
return "", err
}
probeAPIKey := strings.TrimSpace(r.request.ProbeAPIKey)
if probeAPIKey != "" {
if _, err := r.hostClient.AssignSubscription(ctx, sub2api.AssignSubscriptionRequest{
UserID: r.request.SubscriptionUsers[0],
GroupID: groupID,
DurationDays: r.request.SubscriptionDays,
}); err != nil {
return "", err
}
accessRef, err := r.hostClient.EnsureSubscriptionAccess(ctx, sub2api.EnsureSubscriptionAccessRequest{
UserSelector: r.request.SubscriptionUsers[0],
GroupID: groupID,
ProbeAPIKey: probeAPIKey,
})
if err != nil {
return "", err
}
if strings.TrimSpace(accessRef.APIKey) == "" {
return probeAPIKey, nil
}
return strings.TrimSpace(accessRef.APIKey), nil
}
accessRef, err := r.hostClient.EnsureSubscriptionAccess(ctx, sub2api.EnsureSubscriptionAccessRequest{
UserSelector: r.request.SubscriptionUsers[0],
GroupID: groupID,

View File

@@ -2239,6 +2239,9 @@ func TestActionSetAssignAccessSubscriptionsClosure(t *testing.T) {
case r.URL.Path == "/v1/models" && strings.HasPrefix(strings.TrimSpace(r.Header.Get("Authorization")), "Bearer sk-relay-"):
writeJSON(w, http.StatusOK, map[string]any{"data": []map[string]any{{"id": "deepseek-v4-pro"}}})
return
case r.URL.Path == "/v1/models" && strings.TrimSpace(r.Header.Get("Authorization")) == "Bearer caller-probe-key":
writeJSON(w, http.StatusOK, map[string]any{"data": []map[string]any{{"id": "deepseek-v4-pro"}}})
return
case r.URL.Path == "/v1/chat/completions" && strings.HasPrefix(strings.TrimSpace(r.Header.Get("Authorization")), "Bearer sk-relay-"):
writeJSON(w, http.StatusOK, map[string]any{
"id": "chatcmpl_subscription",
@@ -2251,6 +2254,32 @@ func TestActionSetAssignAccessSubscriptionsClosure(t *testing.T) {
}},
})
return
case r.URL.Path == "/v1/chat/completions" && strings.TrimSpace(r.Header.Get("Authorization")) == "Bearer caller-probe-key":
writeJSON(w, http.StatusOK, map[string]any{
"id": "chatcmpl_subscription",
"choices": []map[string]any{{
"index": 0,
"message": map[string]any{
"role": "assistant",
"content": "pong",
},
}},
})
return
case r.URL.Path == "/api/v1/admin/users/1/api-keys":
writeJSON(w, http.StatusOK, map[string]any{
"data": map[string]any{
"items": []map[string]any{{
"id": 501,
"key": "caller-probe-key",
"name": "caller-key",
}},
},
})
return
case r.URL.Path == "/api/v1/admin/api-keys/501" && r.Method == http.MethodPut:
writeJSON(w, http.StatusOK, map[string]any{"data": map[string]any{"api_key": map[string]any{"id": 501}}})
return
}
baseHandler.ServeHTTP(w, r)
}))
@@ -2304,7 +2333,7 @@ func TestActionSetAssignAccessSubscriptionsClosure(t *testing.T) {
PackPath: packPath,
ProviderID: "deepseek",
AccessAPIKey: "caller-probe-key",
SubscriptionUsers: []string{"crm-user-1"},
SubscriptionUsers: []string{"1"},
SubscriptionDays: 30,
})
if err != nil {
@@ -2331,8 +2360,8 @@ func TestActionSetAssignAccessSubscriptionsClosure(t *testing.T) {
if got, _ := payload["requested_probe_api_key"].(string); got != "caller-probe-key" {
t.Fatalf("requested_probe_api_key = %q, want caller-probe-key", got)
}
if got, _ := payload["effective_probe_key_source"].(string); got != "managed_subscription" {
t.Fatalf("effective_probe_key_source = %q, want managed_subscription", got)
if got, _ := payload["effective_probe_key_source"].(string); got != "requested_probe_api_key" {
t.Fatalf("effective_probe_key_source = %q, want requested_probe_api_key", got)
}
batchRow, err := store.ImportBatches().GetByID(context.Background(), batchID)
if err != nil {

View File

@@ -167,6 +167,7 @@ func reconcileProbeAPIKey(ctx context.Context, store *sqlite.DB, hostRow sqlite.
return "", fmt.Errorf("subscription access closure missing subscription_users")
}
subscriptionDays := parseJSONInt(details["subscription_days"])
requestedProbeAPIKey, _ := details["requested_probe_api_key"].(string)
groupID, err := resolveManagedResourceHostIDByBatch(ctx, store, batch.ID, "group")
if err != nil {
return "", err
@@ -175,6 +176,31 @@ func reconcileProbeAPIKey(ctx context.Context, store *sqlite.DB, hostRow sqlite.
if err != nil {
return "", err
}
requestedProbeAPIKey = strings.TrimSpace(requestedProbeAPIKey)
if requestedProbeAPIKey != "" {
if subscriptionDays > 0 {
if _, err := client.AssignSubscription(ctx, sub2api.AssignSubscriptionRequest{
UserID: subscriptionUsers[0],
GroupID: groupID,
DurationDays: subscriptionDays,
}); err != nil {
return "", err
}
}
accessRef, err := client.EnsureSubscriptionAccess(ctx, sub2api.EnsureSubscriptionAccessRequest{
UserSelector: subscriptionUsers[0],
GroupID: groupID,
ProbeAPIKey: requestedProbeAPIKey,
})
if err != nil {
return "", err
}
if strings.TrimSpace(accessRef.APIKey) == "" {
return requestedProbeAPIKey, nil
}
return strings.TrimSpace(accessRef.APIKey), nil
}
accessRef, err := client.EnsureSubscriptionAccess(ctx, sub2api.EnsureSubscriptionAccessRequest{
UserSelector: subscriptionUsers[0],
GroupID: groupID,

View File

@@ -151,6 +151,7 @@ type AssignSubscriptionRequest struct {
type EnsureSubscriptionAccessRequest struct {
UserSelector string
GroupID string
ProbeAPIKey string
}
type SubscriptionAccessRef struct {

View File

@@ -851,7 +851,7 @@ func TestAssignSubscriptionWithMock(t *testing.T) {
}
}
func TestEnsureSubscriptionAccessWithMock(t *testing.T) {
func TestEnsureSubscriptionAccessManagedProbeWithMock(t *testing.T) {
var calls []string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls = append(calls, r.Method+" "+r.URL.Path)
@@ -911,6 +911,45 @@ func TestEnsureSubscriptionAccessWithMock(t *testing.T) {
}
}
func TestEnsureSubscriptionAccessRealUserProbeWithMock(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/admin/users/1/api-keys":
w.Write([]byte(`{"data":{"items":[{"id":501,"key":"caller-probe-key","name":"user-key"}]}}`))
case r.Method == http.MethodPut && r.URL.Path == "/api/v1/admin/api-keys/501":
var req struct {
GroupID int64 `json:"group_id"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
t.Fatalf("decode bind api key request: %v", err)
}
if req.GroupID != 101 {
t.Fatalf("group id = %d, want 101", req.GroupID)
}
w.Write([]byte(`{"data":{"api_key":{"id":501}}}`))
default:
w.WriteHeader(http.StatusNotFound)
}
}))
defer srv.Close()
client, _ := NewClient(srv.URL, WithBearerToken("admin-token"))
ref, err := client.EnsureSubscriptionAccess(context.Background(), EnsureSubscriptionAccessRequest{
UserSelector: "1",
GroupID: "101",
ProbeAPIKey: "caller-probe-key",
})
if err != nil {
t.Fatal(err)
}
if ref.UserID != "1" {
t.Fatalf("user id = %q, want 1", ref.UserID)
}
if ref.APIKey != "caller-probe-key" {
t.Fatalf("api key = %q, want caller-probe-key", ref.APIKey)
}
}
func TestCheckGatewayAccessWithMock(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if got := r.Header.Get("Authorization"); got != "Bearer gk" {

View File

@@ -52,6 +52,9 @@ func (c *Client) EnsureSubscriptionAccess(ctx context.Context, req EnsureSubscri
if err != nil {
return SubscriptionAccessRef{}, fmt.Errorf("parse group id %q: %w", groupID, err)
}
if probeAPIKey := strings.TrimSpace(req.ProbeAPIKey); probeAPIKey != "" {
return c.ensureRealSubscriptionAccess(ctx, selector, groupInt, probeAPIKey)
}
identity := buildManagedSubscriptionIdentity(selector, groupID)
user, err := c.findManagedSubscriptionUser(ctx, identity.Email)
@@ -82,7 +85,7 @@ func (c *Client) EnsureSubscriptionAccess(ctx context.Context, req EnsureSubscri
if err != nil {
return SubscriptionAccessRef{}, err
}
if err := c.bindManagedSubscriptionAPIKey(ctx, keyRecord.ID, groupInt); err != nil {
if err := c.bindAPIKeyGroup(ctx, keyRecord.ID, groupInt); err != nil {
return SubscriptionAccessRef{}, err
}
return SubscriptionAccessRef{UserID: strconv.FormatInt(user.ID, 10), APIKey: identity.CustomKey}, nil
@@ -297,11 +300,55 @@ func (c *Client) findManagedSubscriptionAPIKey(ctx context.Context, userID int64
return nil, fmt.Errorf("managed api key %q not found for user %d", identity.KeyName, userID)
}
func (c *Client) bindManagedSubscriptionAPIKey(ctx context.Context, keyID, groupID int64) error {
func (c *Client) ensureRealSubscriptionAccess(ctx context.Context, selector string, groupID int64, probeAPIKey string) (SubscriptionAccessRef, error) {
userID, err := strconv.ParseInt(strings.TrimSpace(selector), 10, 64)
if err != nil {
return SubscriptionAccessRef{}, fmt.Errorf("parse real subscription user id %q: %w", selector, err)
}
keyRecord, err := c.findUserAPIKeyByRawKey(ctx, userID, probeAPIKey)
if err != nil {
return SubscriptionAccessRef{}, err
}
if err := c.bindAPIKeyGroup(ctx, keyRecord.ID, groupID); err != nil {
return SubscriptionAccessRef{}, err
}
return SubscriptionAccessRef{
UserID: strconv.FormatInt(userID, 10),
APIKey: probeAPIKey,
}, nil
}
func (c *Client) findUserAPIKeyByRawKey(ctx context.Context, userID int64, rawKey string) (*adminAPIKeyRecord, error) {
statusCode, _, body, err := c.perform(ctx, http.MethodGet, fmt.Sprintf("/api/v1/admin/users/%d/api-keys?page=1&page_size=1000&sort_by=created_at&sort_order=desc", userID), nil)
if err != nil {
return nil, fmt.Errorf("list user api keys: %w", err)
}
if statusCode < http.StatusOK || statusCode >= http.StatusMultipleChoices {
return nil, newHTTPError(http.MethodGet, fmt.Sprintf("/api/v1/admin/users/%d/api-keys", userID), statusCode, body)
}
var envelope struct {
Data struct {
Items []adminAPIKeyRecord `json:"items"`
} `json:"data"`
}
if err := json.Unmarshal(body, &envelope); err != nil {
return nil, fmt.Errorf("decode user api keys response: %w", err)
}
trimmedRawKey := strings.TrimSpace(rawKey)
for _, item := range envelope.Data.Items {
if strings.TrimSpace(item.Key) == trimmedRawKey {
key := item
return &key, nil
}
}
return nil, fmt.Errorf("probe api key not found for user %d", userID)
}
func (c *Client) bindAPIKeyGroup(ctx context.Context, keyID, groupID int64) error {
payload := map[string]any{"group_id": groupID}
statusCode, _, body, err := c.perform(ctx, http.MethodPut, fmt.Sprintf("/api/v1/admin/api-keys/%d", keyID), payload)
if err != nil {
return fmt.Errorf("bind managed api key group: %w", err)
return fmt.Errorf("bind api key group: %w", err)
}
if statusCode < http.StatusOK || statusCode >= http.StatusMultipleChoices {
return newHTTPError(http.MethodPut, fmt.Sprintf("/api/v1/admin/api-keys/%d", keyID), statusCode, body)