Skip to content

Commit a1e205e

Browse files
committed
feat: limit community API keys
1 parent 7509b40 commit a1e205e

8 files changed

Lines changed: 683 additions & 322 deletions

File tree

api/v1/api.gen.go

Lines changed: 319 additions & 318 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

api/v1/api.yaml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -555,7 +555,7 @@ paths:
555555

556556
post:
557557
summary: "Create API key"
558-
description: "Full key returned only in this response"
558+
description: "Full key returned only in this response. Community edition installs can create up to 2 API keys."
559559
operationId: "createAPIKey"
560560
tags:
561561
- "api-keys"
@@ -585,7 +585,7 @@ paths:
585585
schema:
586586
$ref: "#/components/schemas/Error"
587587
"403":
588-
description: "Requires admin role"
588+
description: "Requires admin role or community API key limit reached"
589589
content:
590590
application/json:
591591
schema:

internal/license/testing.go

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,14 +15,25 @@ import (
1515
// It generates an ephemeral ed25519 key pair, signs a JWT with the requested features,
1616
// and updates the manager's internal state so Checker() returns a licensed state.
1717
func NewTestManager(features ...string) *Manager {
18+
return newTestManager(time.Now().Add(24*time.Hour), nil, features...)
19+
}
20+
21+
// NewExpiredTestManager creates a Manager with a loaded license that is expired
22+
// and outside its grace period.
23+
func NewExpiredTestManager(features ...string) *Manager {
24+
zeroGraceDays := 0
25+
return newTestManager(time.Now().Add(-time.Hour), &zeroGraceDays, features...)
26+
}
27+
28+
func newTestManager(expiresAt time.Time, graceDays *int, features ...string) *Manager {
1829
pub, priv, err := ed25519.GenerateKey(nil)
1930
if err != nil {
2031
panic("ed25519.GenerateKey: " + err.Error())
2132
}
2233

2334
claims := &LicenseClaims{
2435
RegisteredClaims: jwt.RegisteredClaims{
25-
ExpiresAt: jwt.NewNumericDate(time.Now().Add(24 * time.Hour)),
36+
ExpiresAt: jwt.NewNumericDate(expiresAt),
2637
IssuedAt: jwt.NewNumericDate(time.Now()),
2738
Issuer: "dagu-test",
2839
Subject: "test-license",
@@ -31,6 +42,7 @@ func NewTestManager(features ...string) *Manager {
3142
Plan: "pro",
3243
Features: features,
3344
ActivationID: "act-test",
45+
GraceDays: graceDays,
3446
}
3547

3648
token := jwt.NewWithClaims(jwt.SigningMethodEdDSA, claims)

internal/service/frontend/api/v1/api.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import (
1212
"net/http"
1313
"reflect"
1414
"strings"
15+
"sync"
1516
"time"
1617

1718
"github.com/dagucloud/dagu/api/v1"
@@ -88,6 +89,7 @@ type API struct {
8889
baseConfigStore baseconfig.Store
8990
secretStore secretpkg.Store
9091
licenseManager *license.Manager
92+
apiKeyCreateMu sync.Mutex
9193
workspaceStore workspace.Store
9294
leaseStaleThreshold time.Duration
9395
schedulerStateStore scheduler.WatermarkStore

internal/service/frontend/api/v1/apikeys.go

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,13 @@ import (
1010

1111
"github.com/dagucloud/dagu/api/v1"
1212
"github.com/dagucloud/dagu/internal/auth"
13+
"github.com/dagucloud/dagu/internal/license"
1314
"github.com/dagucloud/dagu/internal/service/audit"
1415
authservice "github.com/dagucloud/dagu/internal/service/auth"
1516
)
1617

18+
const communityAPIKeyLimit = 2
19+
1720
// ListAPIKeys returns a list of all API keys. Requires admin role.
1821
func (a *API) ListAPIKeys(ctx context.Context, _ api.ListAPIKeysRequestObject) (api.ListAPIKeysResponseObject, error) {
1922
if err := a.requireAPIKeyManagement(); err != nil {
@@ -73,6 +76,13 @@ func (a *API) CreateAPIKey(ctx context.Context, request api.CreateAPIKeyRequestO
7376
}
7477
}
7578

79+
a.apiKeyCreateMu.Lock()
80+
defer a.apiKeyCreateMu.Unlock()
81+
82+
if err := a.requireCommunityAPIKeyLimit(ctx); err != nil {
83+
return nil, err
84+
}
85+
7686
result, err := a.authService.CreateAPIKey(ctx, authservice.CreateAPIKeyInput{
7787
Name: request.Body.Name,
7888
Description: valueOf(request.Body.Description),
@@ -292,6 +302,34 @@ func (a *API) requireAPIKeyManagement() error {
292302
return nil
293303
}
294304

305+
func (a *API) requireCommunityAPIKeyLimit(ctx context.Context) error {
306+
if !a.isCommunityLicense() {
307+
return nil
308+
}
309+
310+
keys, err := a.authService.ListAPIKeys(ctx)
311+
if err != nil {
312+
return err
313+
}
314+
if len(keys) < communityAPIKeyLimit {
315+
return nil
316+
}
317+
318+
return &Error{
319+
Code: api.ErrorCodeForbidden,
320+
Message: "Community edition supports up to 2 API keys. " +
321+
"Delete an existing key or configure a license to create more.",
322+
HTTPStatus: http.StatusForbidden,
323+
}
324+
}
325+
326+
func (a *API) isCommunityLicense() bool {
327+
if a.licenseManager == nil {
328+
return true
329+
}
330+
return !license.HasActiveLicense(a.licenseManager.Checker())
331+
}
332+
295333
// toAPIKey converts a core auth.APIKey into its API representation.
296334
func toAPIKey(key *auth.APIKey) api.APIKey {
297335
return api.APIKey{

internal/service/frontend/api/v1/apikeys_test.go

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010

1111
"github.com/dagucloud/dagu/api/v1"
1212
"github.com/dagucloud/dagu/internal/cmn/config"
13+
"github.com/dagucloud/dagu/internal/license"
1314
"github.com/dagucloud/dagu/internal/service/frontend"
1415
"github.com/dagucloud/dagu/internal/test"
1516
"github.com/stretchr/testify/assert"
@@ -50,6 +51,36 @@ func setupBuiltinAuthServer(t *testing.T) test.Server {
5051
return server
5152
}
5253

54+
func setupBuiltinAuthCommunityServer(t *testing.T) test.Server {
55+
t.Helper()
56+
return setupBuiltinAuthTestServer(t)
57+
}
58+
59+
func setupBuiltinAuthExpiredLicenseServer(t *testing.T) test.Server {
60+
t.Helper()
61+
return setupBuiltinAuthTestServer(t, frontend.WithLicenseManager(license.NewExpiredTestManager()))
62+
}
63+
64+
func setupBuiltinAuthTestServer(t *testing.T, opts ...frontend.ServerOption) test.Server {
65+
t.Helper()
66+
server := test.SetupServer(t,
67+
test.WithConfigMutator(func(cfg *config.Config) {
68+
cfg.Server.Auth.Mode = config.AuthModeBuiltin
69+
cfg.Server.Auth.Builtin.Token.Secret = "jwt-secret-key"
70+
cfg.Server.Auth.Builtin.Token.TTL = 24 * time.Hour
71+
}),
72+
test.WithServerOptions(opts...),
73+
)
74+
75+
// Create admin via setup endpoint
76+
server.Client().Post("/api/v1/auth/setup", api.SetupRequest{
77+
Username: "admin",
78+
Password: "adminpass",
79+
}).ExpectStatus(http.StatusOK).Send(t)
80+
81+
return server
82+
}
83+
5384
// TestAPIKeys_ListEmpty tests listing API keys when none exist
5485
func TestAPIKeys_ListEmpty(t *testing.T) {
5586
t.Parallel()
@@ -218,6 +249,68 @@ func TestAPIKeys_CreateDuplicate(t *testing.T) {
218249
}).WithBearerToken(token).ExpectStatus(http.StatusConflict).Send(t)
219250
}
220251

252+
func TestAPIKeys_CreateCommunityLimit(t *testing.T) {
253+
t.Parallel()
254+
server := setupBuiltinAuthCommunityServer(t)
255+
token := getAdminToken(t, server)
256+
257+
for _, name := range []string{"community-key-1", "community-key-2"} {
258+
server.Client().Post("/api/v1/api-keys", api.CreateAPIKeyRequest{
259+
Name: name,
260+
Role: api.UserRoleViewer,
261+
}).WithBearerToken(token).ExpectStatus(http.StatusCreated).Send(t)
262+
}
263+
264+
resp := server.Client().Post("/api/v1/api-keys", api.CreateAPIKeyRequest{
265+
Name: "community-key-3",
266+
Role: api.UserRoleViewer,
267+
}).WithBearerToken(token).ExpectStatus(http.StatusForbidden).Send(t)
268+
269+
var errResp api.Error
270+
resp.Unmarshal(t, &errResp)
271+
assert.Equal(t, api.ErrorCodeForbidden, errResp.Code)
272+
assert.Contains(t, errResp.Message, "Community edition supports up to 2 API keys")
273+
274+
listResp := server.Client().Get("/api/v1/api-keys").
275+
WithBearerToken(token).
276+
ExpectStatus(http.StatusOK).Send(t)
277+
278+
var listResult api.APIKeysListResponse
279+
listResp.Unmarshal(t, &listResult)
280+
assert.Len(t, listResult.ApiKeys, 2)
281+
}
282+
283+
func TestAPIKeys_CreateExpiredLicenseUsesCommunityLimit(t *testing.T) {
284+
t.Parallel()
285+
server := setupBuiltinAuthExpiredLicenseServer(t)
286+
token := getAdminToken(t, server)
287+
288+
for _, name := range []string{"expired-key-1", "expired-key-2"} {
289+
server.Client().Post("/api/v1/api-keys", api.CreateAPIKeyRequest{
290+
Name: name,
291+
Role: api.UserRoleViewer,
292+
}).WithBearerToken(token).ExpectStatus(http.StatusCreated).Send(t)
293+
}
294+
295+
server.Client().Post("/api/v1/api-keys", api.CreateAPIKeyRequest{
296+
Name: "expired-key-3",
297+
Role: api.UserRoleViewer,
298+
}).WithBearerToken(token).ExpectStatus(http.StatusForbidden).Send(t)
299+
}
300+
301+
func TestAPIKeys_CreateLicensedAllowsMoreThanCommunityLimit(t *testing.T) {
302+
t.Parallel()
303+
server := setupBuiltinAuthServer(t)
304+
token := getAdminToken(t, server)
305+
306+
for _, name := range []string{"licensed-key-1", "licensed-key-2", "licensed-key-3"} {
307+
server.Client().Post("/api/v1/api-keys", api.CreateAPIKeyRequest{
308+
Name: name,
309+
Role: api.UserRoleViewer,
310+
}).WithBearerToken(token).ExpectStatus(http.StatusCreated).Send(t)
311+
}
312+
}
313+
221314
// TestAPIKeys_GetNotFound tests getting a non-existent API key
222315
func TestAPIKeys_GetNotFound(t *testing.T) {
223316
t.Parallel()

0 commit comments

Comments
 (0)