Skip to content

Commit 11b7a88

Browse files
author
Giulio Savini
committed
fix: allow null user_id on env bootstrap keys, enable H2 for registry fetches
Two bugs in one branch: 1. Deploying a swarm node-level agent from the agent itself failed with a FK violation because CreateEnvironmentApiKey was inserting user_id='agent' (the synthetic agent user) which doesn't exist in the users table. Environment bootstrap keys belong to the system, not a user — make user_id nullable in api_keys and pass nil when creating env keys (migration 046). 2. Fetching remote template registries over HTTPS broke with "net/http: HTTP/1.x transport connection broken: malformed HTTP response" because the custom http.Transport used throughout the app didn't have ForceAttemptHTTP2 set, so Go disabled H2 negotiation entirely when a custom DialContext was applied in the safe-HTTP wrapper. Add ForceAttemptHTTP2: true to both constructors in httpx/client.go. Closes #2369 Closes #2367
1 parent 70d7a69 commit 11b7a88

File tree

11 files changed

+148
-16
lines changed

11 files changed

+148
-16
lines changed

backend/internal/huma/handlers/dashboard_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,7 @@ func TestDashboardHandlerGetDashboardReturnsSnapshot(t *testing.T) {
105105
Name: "expiring-soon",
106106
KeyHash: "hash-soon",
107107
KeyPrefix: "arc_test_handler",
108-
UserID: "user-1",
108+
UserID: new("user-1"),
109109
ExpiresAt: new(time.Now().Add(12 * time.Hour)),
110110
}).Error)
111111

backend/internal/models/api_key.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ type ApiKey struct {
1010
KeyHash string `json:"-" gorm:"column:key_hash;not null"`
1111
KeyPrefix string `json:"keyPrefix" gorm:"column:key_prefix;not null"`
1212
ManagedBy *string `json:"-" gorm:"column:managed_by"`
13-
UserID string `json:"userId" gorm:"column:user_id;not null"`
13+
UserID *string `json:"userId,omitempty" gorm:"column:user_id"`
1414
EnvironmentID *string `json:"environmentId,omitempty" gorm:"column:environment_id"`
1515
ExpiresAt *time.Time `json:"expiresAt,omitempty" gorm:"column:expires_at" sortable:"true"`
1616
LastUsedAt *time.Time `json:"lastUsedAt,omitempty" gorm:"column:last_used_at" sortable:"true"`

backend/internal/services/api_key_service.go

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -105,12 +105,12 @@ func (s *ApiKeyService) CreateApiKey(ctx context.Context, userID string, req api
105105
return nil, err
106106
}
107107

108-
return s.createAPIKeyWithRawKey(ctx, userID, rawKey, req, nil, nil)
108+
return s.createAPIKeyWithRawKey(ctx, &userID, rawKey, req, nil, nil)
109109
}
110110

111111
func (s *ApiKeyService) createAPIKeyWithRawKey(
112112
ctx context.Context,
113-
userID string,
113+
userID *string,
114114
rawKey string,
115115
req apikey.CreateApiKey,
116116
managedBy *string,
@@ -168,7 +168,7 @@ func toAPIKeyDTOInternal(ak *models.ApiKey) apikey.ApiKey {
168168
}
169169

170170
func (s *ApiKeyService) CreateDefaultAdminAPIKey(ctx context.Context, userID, rawKey string) (*apikey.ApiKeyCreatedDto, error) {
171-
return s.createAPIKeyWithRawKey(ctx, userID, rawKey, apikey.CreateApiKey{
171+
return s.createAPIKeyWithRawKey(ctx, &userID, rawKey, apikey.CreateApiKey{
172172
Name: defaultAdminAPIKeyName,
173173
Description: defaultAdminAPIKeyDescription,
174174
}, new(managedByAdminBootstrap), nil)
@@ -258,7 +258,7 @@ func (s *ApiKeyService) createManagedDefaultAdminAPIKey(tx *gorm.DB, userID, raw
258258
KeyHash: keyHash,
259259
KeyPrefix: keyPrefix,
260260
ManagedBy: new(managedByAdminBootstrap),
261-
UserID: userID,
261+
UserID: &userID,
262262
}
263263

264264
if err := tx.Create(ak).Error; err != nil {
@@ -316,7 +316,7 @@ func (s *ApiKeyService) CreateEnvironmentApiKey(ctx context.Context, environment
316316
envIDShort = environmentID[:8]
317317
}
318318
name := fmt.Sprintf("Environment Bootstrap Key - %s", envIDShort)
319-
return s.createAPIKeyWithRawKey(ctx, userID, rawKey, apikey.CreateApiKey{
319+
return s.createAPIKeyWithRawKey(ctx, nil, rawKey, apikey.CreateApiKey{
320320
Name: name,
321321
Description: new("Auto-generated key for environment pairing"),
322322
}, nil, &environmentID)
@@ -452,7 +452,11 @@ func (s *ApiKeyService) ValidateApiKey(ctx context.Context, rawKey string) (*mod
452452

453453
s.markApiKeyUsedAsync(ctx, apiKey.ID)
454454

455-
user, err := s.userService.GetUserByID(ctx, apiKey.UserID)
455+
if apiKey.UserID == nil {
456+
return nil, ErrApiKeyInvalid
457+
}
458+
459+
user, err := s.userService.GetUserByID(ctx, *apiKey.UserID)
456460
if err != nil {
457461
return nil, fmt.Errorf("failed to get user for API key: %w", err)
458462
}

backend/internal/services/dashboard_service_test.go

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -90,28 +90,28 @@ func TestDashboardService_GetActionItems_IncludesExpiringAPIKeys(t *testing.T) {
9090
Name: "expiring-soon",
9191
KeyHash: "hash-soon",
9292
KeyPrefix: "arc_test_s",
93-
UserID: "user-1",
93+
UserID: new("user-1"),
9494
ExpiresAt: new(now.Add(24 * time.Hour)),
9595
})
9696
createDashboardTestAPIKey(t, db, models.ApiKey{
9797
Name: "already-expired",
9898
KeyHash: "hash-expired",
9999
KeyPrefix: "arc_test_e",
100-
UserID: "user-1",
100+
UserID: new("user-1"),
101101
ExpiresAt: new(now.Add(-24 * time.Hour)),
102102
})
103103
createDashboardTestAPIKey(t, db, models.ApiKey{
104104
Name: "future",
105105
KeyHash: "hash-future",
106106
KeyPrefix: "arc_test_f",
107-
UserID: "user-1",
107+
UserID: new("user-1"),
108108
ExpiresAt: new(now.Add(45 * 24 * time.Hour)),
109109
})
110110
createDashboardTestAPIKey(t, db, models.ApiKey{
111111
Name: "never-expires",
112112
KeyHash: "hash-never",
113113
KeyPrefix: "arc_test_n",
114-
UserID: "user-1",
114+
UserID: new("user-1"),
115115
})
116116

117117
actionItems, err := svc.GetActionItems(context.Background(), DashboardActionItemsOptions{})
@@ -133,7 +133,7 @@ func TestDashboardService_GetActionItems_DebugAllGoodReturnsNoItems(t *testing.T
133133
Name: "expiring-soon",
134134
KeyHash: "hash-soon",
135135
KeyPrefix: "arc_test_d",
136-
UserID: "user-1",
136+
UserID: new("user-1"),
137137
ExpiresAt: new(time.Now().Add(2 * time.Hour)),
138138
})
139139

@@ -194,7 +194,7 @@ func TestDashboardService_GetSnapshot_ReturnsDashboardSnapshot(t *testing.T) {
194194
Name: "expiring-soon",
195195
KeyHash: "hash-soon",
196196
KeyPrefix: "arc_test_snapshot",
197-
UserID: "user-1",
197+
UserID: new("user-1"),
198198
ExpiresAt: new(time.Now().Add(12 * time.Hour)),
199199
})
200200

backend/internal/services/environment_service_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -533,7 +533,7 @@ func TestEnvironmentService_EnsureSwarmNodeAgentEnvironment_CreatesHiddenChildAn
533533
Order("created_at asc").
534534
Find(&apiKeys).Error)
535535
require.Len(t, apiKeys, 1)
536-
require.Equal(t, user.ID, apiKeys[0].UserID)
536+
require.Nil(t, apiKeys[0].UserID) // environment bootstrap keys have no owner
537537

538538
reusedEnv, reusedToken, err := svc.EnsureSwarmNodeAgentEnvironment(
539539
ctx,

backend/pkg/utils/httpx/client.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ func NewHTTPClient() *http.Client {
1212
IdleConnTimeout: 90 * time.Second,
1313
TLSHandshakeTimeout: 5 * time.Second,
1414
ExpectContinueTimeout: 1 * time.Second,
15+
ForceAttemptHTTP2: true,
1516
}
1617
return &http.Client{
1718
Transport: transport,
@@ -26,6 +27,7 @@ func NewHTTPClientWithTimeout(timeout time.Duration) *http.Client {
2627
IdleConnTimeout: 90 * time.Second,
2728
TLSHandshakeTimeout: 10 * time.Second,
2829
ExpectContinueTimeout: 1 * time.Second,
30+
ForceAttemptHTTP2: true,
2931
}
3032
return &http.Client{
3133
Transport: transport,
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
-- Remove environment bootstrap keys (user_id IS NULL) before restoring the NOT NULL constraint.
2+
DELETE FROM api_keys WHERE user_id IS NULL;
3+
ALTER TABLE api_keys ALTER COLUMN user_id SET NOT NULL;
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
-- Environment bootstrap keys are owned by the system, not a user.
2+
-- Allow user_id to be NULL so agent-side key creation doesn't violate the FK constraint.
3+
ALTER TABLE api_keys ALTER COLUMN user_id DROP NOT NULL;
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
-- Remove environment bootstrap keys (user_id IS NULL) before restoring the NOT NULL constraint.
2+
PRAGMA foreign_keys=OFF;
3+
4+
DROP TABLE IF EXISTS api_keys_old;
5+
6+
CREATE TABLE api_keys_old (
7+
id TEXT PRIMARY KEY,
8+
name TEXT NOT NULL,
9+
description TEXT,
10+
key_hash TEXT NOT NULL,
11+
key_prefix TEXT NOT NULL,
12+
managed_by TEXT,
13+
user_id TEXT NOT NULL,
14+
environment_id TEXT,
15+
expires_at DATETIME,
16+
last_used_at DATETIME,
17+
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
18+
updated_at DATETIME,
19+
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
20+
FOREIGN KEY (environment_id) REFERENCES environments(id) ON DELETE CASCADE
21+
);
22+
23+
INSERT INTO api_keys_old (
24+
id,
25+
name,
26+
description,
27+
key_hash,
28+
key_prefix,
29+
managed_by,
30+
user_id,
31+
environment_id,
32+
expires_at,
33+
last_used_at,
34+
created_at,
35+
updated_at
36+
)
37+
SELECT
38+
id,
39+
name,
40+
description,
41+
key_hash,
42+
key_prefix,
43+
managed_by,
44+
user_id,
45+
environment_id,
46+
expires_at,
47+
last_used_at,
48+
created_at,
49+
updated_at
50+
FROM api_keys
51+
WHERE user_id IS NOT NULL;
52+
53+
DROP TABLE api_keys;
54+
ALTER TABLE api_keys_old RENAME TO api_keys;
55+
56+
CREATE INDEX IF NOT EXISTS idx_api_keys_user_id ON api_keys(user_id);
57+
CREATE INDEX IF NOT EXISTS idx_api_keys_key_hash ON api_keys(key_hash);
58+
CREATE INDEX IF NOT EXISTS idx_api_keys_key_prefix ON api_keys(key_prefix);
59+
60+
PRAGMA foreign_keys=ON;
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
-- Environment bootstrap keys are owned by the system, not a user.
2+
-- Allow user_id to be NULL so agent-side key creation doesn't violate the FK constraint.
3+
PRAGMA foreign_keys=OFF;
4+
5+
DROP TABLE IF EXISTS api_keys_new;
6+
7+
CREATE TABLE api_keys_new (
8+
id TEXT PRIMARY KEY,
9+
name TEXT NOT NULL,
10+
description TEXT,
11+
key_hash TEXT NOT NULL,
12+
key_prefix TEXT NOT NULL,
13+
managed_by TEXT,
14+
user_id TEXT,
15+
environment_id TEXT,
16+
expires_at DATETIME,
17+
last_used_at DATETIME,
18+
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
19+
updated_at DATETIME,
20+
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
21+
FOREIGN KEY (environment_id) REFERENCES environments(id) ON DELETE CASCADE
22+
);
23+
24+
INSERT INTO api_keys_new (
25+
id,
26+
name,
27+
description,
28+
key_hash,
29+
key_prefix,
30+
managed_by,
31+
user_id,
32+
environment_id,
33+
expires_at,
34+
last_used_at,
35+
created_at,
36+
updated_at
37+
)
38+
SELECT
39+
id,
40+
name,
41+
description,
42+
key_hash,
43+
key_prefix,
44+
managed_by,
45+
user_id,
46+
environment_id,
47+
expires_at,
48+
last_used_at,
49+
created_at,
50+
updated_at
51+
FROM api_keys;
52+
53+
DROP TABLE api_keys;
54+
ALTER TABLE api_keys_new RENAME TO api_keys;
55+
56+
CREATE INDEX IF NOT EXISTS idx_api_keys_user_id ON api_keys(user_id);
57+
CREATE INDEX IF NOT EXISTS idx_api_keys_key_hash ON api_keys(key_hash);
58+
CREATE INDEX IF NOT EXISTS idx_api_keys_key_prefix ON api_keys(key_prefix);
59+
60+
PRAGMA foreign_keys=ON;

0 commit comments

Comments
 (0)