API Token Scopes + API Key as Agent Registration Token
For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
Goal: Add permission scopes to API tokens so they can grant restricted access, and allow API tokens with nodes.create scope to be used as agent registration tokens (reusable, fixed, good for IaC/automation).
Architecture: Add a scopes TEXT[] column to api_tokens. Empty scopes = full user access (backwards compatible). Non-empty scopes = intersection with user's RBAC permissions. In Register, detect shardlyn_api_ prefix → validate as API token, require nodes.create scope, create node directly into the requested org.
Tech Stack: Go, PostgreSQL (TEXT[]), SQLite (JSON string via types.StringArray), golang-migrate, chi router.
Task 1: PostgreSQL migration — add scopes to api_tokens
Files:
- Create:
migrations/postgres/000072_api_token_scopes.up.sql - Create:
migrations/postgres/000072_api_token_scopes.down.sql
Step 1: Write up migration
migrations/postgres/000072_api_token_scopes.up.sql:
ALTER TABLE api_tokens ADD COLUMN scopes TEXT[] NOT NULL DEFAULT '{}';migrations/postgres/000072_api_token_scopes.down.sql:
ALTER TABLE api_tokens DROP COLUMN scopes;Step 2: Verify files exist
ls migrations/postgres/000072_*Expected: both files listed.
Step 3: Commit
git add migrations/postgres/000072_api_token_scopes.up.sql migrations/postgres/000072_api_token_scopes.down.sql
git commit -m "feat(migrations): add scopes column to api_tokens (postgres)"Task 2: SQLite migration — add scopes to api_tokens
Files:
- Create:
migrations/sqlite/000073_api_token_scopes.up.sql - Create:
migrations/sqlite/000073_api_token_scopes.down.sql
SQLite doesn't support DROP COLUMN before version 3.35; the down migration is a no-op for SQLite (or uses recreate pattern). Follow the project's existing pattern — check migrations/sqlite/000009_credential_expiry.down.sql for how nullifying is done; here a simple no-op is fine since SQLite 3.35+ supports DROP COLUMN.
Step 1: Write migrations
migrations/sqlite/000073_api_token_scopes.up.sql:
ALTER TABLE api_tokens ADD COLUMN scopes TEXT NOT NULL DEFAULT '[]';migrations/sqlite/000073_api_token_scopes.down.sql:
ALTER TABLE api_tokens DROP COLUMN scopes;Step 2: Verify
ls migrations/sqlite/000073_*Step 3: Commit
git add migrations/sqlite/000073_api_token_scopes.up.sql migrations/sqlite/000073_api_token_scopes.down.sql
git commit -m "feat(migrations): add scopes column to api_tokens (sqlite)"Task 3: Update APIToken model
Files:
- Modify:
internal/controlplane/store/models.go:31-39
Step 1: Add Scopes field to APIToken struct
Current (models.go:31-39):
type APIToken struct {
ID uuid.UUID `json:"id"`
UserID uuid.UUID `json:"user_id"`
Name string `json:"name"`
TokenHash string `json:"-"`
LastUsedAt *time.Time `json:"last_used_at,omitempty"`
ExpiresAt *time.Time `json:"expires_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
}Change to:
type APIToken struct {
ID uuid.UUID `json:"id"`
UserID uuid.UUID `json:"user_id"`
Name string `json:"name"`
TokenHash string `json:"-"`
Scopes types.StringArray `json:"scopes,omitempty"`
LastUsedAt *time.Time `json:"last_used_at,omitempty"`
ExpiresAt *time.Time `json:"expires_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
}Step 2: Verify import of types package is present
The file already imports "github.com/caiolombello/shardlyn/internal/controlplane/store/types" (used by other models). If not, add it.
Step 3: Build check
go build ./internal/controlplane/store/...Expected: no errors.
Step 4: Commit
git add internal/controlplane/store/models.go
git commit -m "feat(store): add Scopes field to APIToken model"Task 4: Update APITokenRepo queries to persist and read scopes
Files:
- Modify:
internal/controlplane/store/api_token_repo.go
All three query functions need updating: CreateAPIToken, GetAPITokenByHash, ListAPITokensByUser.
Step 1: Update CreateAPIToken
Change the INSERT query from:
query := `
INSERT INTO api_tokens (id, user_id, name, token_hash, expires_at, created_at)
VALUES ($1, $2, $3, $4, $5, $6)
`
_, err := r.db.Exec(ctx, query,
token.ID,
token.UserID,
token.Name,
token.TokenHash,
token.ExpiresAt,
token.CreatedAt,
)To:
query := `
INSERT INTO api_tokens (id, user_id, name, token_hash, scopes, expires_at, created_at)
VALUES ($1, $2, $3, $4, $5, $6, $7)
`
_, err := r.db.Exec(ctx, query,
token.ID,
token.UserID,
token.Name,
token.TokenHash,
token.Scopes,
token.ExpiresAt,
token.CreatedAt,
)Step 2: Update GetAPITokenByHash
Change the SELECT query from:
query := `
SELECT id, user_id, name, token_hash, last_used_at, expires_at, created_at
FROM api_tokens WHERE token_hash = $1
`
token := &APIToken{}
err := r.db.QueryRow(ctx, query, hash).Scan(
&token.ID,
&token.UserID,
&token.Name,
&token.TokenHash,
&token.LastUsedAt,
&token.ExpiresAt,
&token.CreatedAt,
)To:
query := `
SELECT id, user_id, name, token_hash, scopes, last_used_at, expires_at, created_at
FROM api_tokens WHERE token_hash = $1
`
token := &APIToken{}
err := r.db.QueryRow(ctx, query, hash).Scan(
&token.ID,
&token.UserID,
&token.Name,
&token.TokenHash,
&token.Scopes,
&token.LastUsedAt,
&token.ExpiresAt,
&token.CreatedAt,
)Step 3: Update ListAPITokensByUser
Change the SELECT query from:
query := `
SELECT id, user_id, name, token_hash, last_used_at, expires_at, created_at
FROM api_tokens WHERE user_id = $1 ORDER BY created_at DESC
`
...
if err := rows.Scan(
&t.ID,
&t.UserID,
&t.Name,
&t.TokenHash,
&t.LastUsedAt,
&t.ExpiresAt,
&t.CreatedAt,
); err != nil {To:
query := `
SELECT id, user_id, name, token_hash, scopes, last_used_at, expires_at, created_at
FROM api_tokens WHERE user_id = $1 ORDER BY created_at DESC
`
...
if err := rows.Scan(
&t.ID,
&t.UserID,
&t.Name,
&t.TokenHash,
&t.Scopes,
&t.LastUsedAt,
&t.ExpiresAt,
&t.CreatedAt,
); err != nil {Step 4: Build check
go build ./internal/controlplane/store/...Step 5: Commit
git add internal/controlplane/store/api_token_repo.go
git commit -m "feat(store): persist and read scopes in api_token_repo"Task 5: Add APITokenScopes to UserContext and scope enforcement
Files:
- Modify:
internal/controlplane/auth/middleware.go:21-32 - Modify:
internal/controlplane/auth/api_token_validator.go:51-56 - Modify:
internal/controlplane/auth/permission.go:38-55
5a: Add field to UserContext
Current UserContext struct (middleware.go:21-32):
type UserContext struct {
UserID uuid.UUID
Email string
Role string // Global role (admin/user)
// Organization context (populated by RequireOrganization middleware)
OrganizationID *uuid.UUID
OrgRole string // Role in the organization (owner/admin/member)
// Permissions (resolved by PermissionChecker, not persisted)
Permissions map[string]bool `json:"-"`
}Add APITokenScopes field:
type UserContext struct {
UserID uuid.UUID
Email string
Role string // Global role (admin/user)
// Organization context (populated by RequireOrganization middleware)
OrganizationID *uuid.UUID
OrgRole string // Role in the organization (owner/admin/member)
// Permissions (resolved by PermissionChecker, not persisted)
Permissions map[string]bool `json:"-"`
// APITokenScopes restricts permissions when non-empty.
// A non-empty slice means only listed scopes are allowed (intersection with RBAC).
// An empty slice means full user access (no restriction).
APITokenScopes []string `json:"-"`
}Also add a helper method HasScope after the HasPermission method:
// HasAPITokenScope returns true if the context has no scope restriction,
// or if the specified scope is in the allowed list.
func (u *UserContext) HasAPITokenScope(scope string) bool {
if len(u.APITokenScopes) == 0 {
return true // no restriction
}
for _, s := range u.APITokenScopes {
if s == scope {
return true
}
}
return false
}5b: Populate scopes in APITokenValidator
Current return in api_token_validator.go:51-56:
return &UserContext{
UserID: user.ID,
Email: user.Email,
Role: user.Role,
}, nilChange to:
return &UserContext{
UserID: user.ID,
Email: user.Email,
Role: user.Role,
APITokenScopes: apiToken.Scopes,
}, nil5c: Enforce scopes in PermissionChecker.HasPermission
Current function (permission.go:38-55):
func (pc *PermissionChecker) HasPermission(ctx context.Context, user *UserContext, permission string) (bool, error) {
// Global admin bypass
if user.Role == "admin" {
return true, nil
}
// Need org context
if user.OrganizationID == nil {
return false, nil
}
perms, err := pc.getPermissions(ctx, *user.OrganizationID, user.UserID, user.OrgRole)
if err != nil {
return false, err
}
return perms[permission], nil
}Change to:
func (pc *PermissionChecker) HasPermission(ctx context.Context, user *UserContext, permission string) (bool, error) {
// Global admin bypass (but still enforce token scopes even for admins)
if user.Role == "admin" {
return user.HasAPITokenScope(permission), nil
}
// Need org context
if user.OrganizationID == nil {
return false, nil
}
// If token has restricted scopes, check scope first (fast path)
if !user.HasAPITokenScope(permission) {
return false, nil
}
perms, err := pc.getPermissions(ctx, *user.OrganizationID, user.UserID, user.OrgRole)
if err != nil {
return false, err
}
return perms[permission], nil
}Step 1: Apply all three changes above.
Step 2: Build check
go build ./internal/controlplane/auth/...Step 3: Run existing auth tests
go test ./internal/controlplane/auth/... -vExpected: all tests pass.
Step 4: Commit
git add internal/controlplane/auth/middleware.go \
internal/controlplane/auth/api_token_validator.go \
internal/controlplane/auth/permission.go
git commit -m "feat(auth): add APITokenScopes to UserContext with scope enforcement"Task 6: Update API token creation endpoint to accept and return scopes
Files:
- Modify:
internal/controlplane/api/api_token_handler.go
6a: Update request/response types
Current APITokenRequest (api_token_handler.go:34-37):
type APITokenRequest struct {
Name string `json:"name"`
ExpiresIn int `json:"expires_in,omitempty"` // seconds
}Add Scopes field:
type APITokenRequest struct {
Name string `json:"name"`
ExpiresIn int `json:"expires_in,omitempty"` // seconds
Scopes []string `json:"scopes,omitempty"`
}Current APITokenResponse (api_token_handler.go:40-47):
type APITokenResponse struct {
ID string `json:"id"`
Name string `json:"name"`
Token string `json:"token,omitempty"`
LastUsedAt *string `json:"last_used_at,omitempty"`
ExpiresAt *string `json:"expires_at,omitempty"`
CreatedAt string `json:"created_at"`
}Add Scopes field:
type APITokenResponse struct {
ID string `json:"id"`
Name string `json:"name"`
Token string `json:"token,omitempty"`
Scopes []string `json:"scopes,omitempty"`
LastUsedAt *string `json:"last_used_at,omitempty"`
ExpiresAt *string `json:"expires_at,omitempty"`
CreatedAt string `json:"created_at"`
}6b: Add scope validation constants
Add after the apiTokenPrefix const:
// validAPITokenScopes is the set of accepted scope values.
var validAPITokenScopes = map[string]struct{}{
"nodes.create": {},
"nodes.read": {},
"nodes.update": {},
"nodes.delete": {},
"instances.create": {},
"instances.read": {},
"instances.update": {},
"instances.delete": {},
"credentials.read": {},
}6c: Validate scopes in CreateAPIToken
After the req.Name == "" check in CreateAPIToken, add scope validation:
for _, scope := range req.Scopes {
if _, ok := validAPITokenScopes[scope]; !ok {
RespondWithAPIError(w, ValidationError("invalid scope: "+scope, nil))
return
}
}6d: Persist scopes when building apiToken
Change the apiToken struct literal:
apiToken := &store.APIToken{
UserID: currentUser.UserID,
Name: req.Name,
TokenHash: tokenHash,
ExpiresAt: expiresAt,
}To:
apiToken := &store.APIToken{
UserID: currentUser.UserID,
Name: req.Name,
TokenHash: tokenHash,
Scopes: req.Scopes,
ExpiresAt: expiresAt,
}6e: Return scopes in response
Change the response construction:
response := &APITokenResponse{
ID: apiToken.ID.String(),
Name: apiToken.Name,
Token: token,
CreatedAt: apiToken.CreatedAt.Format(time.RFC3339),
}To:
response := &APITokenResponse{
ID: apiToken.ID.String(),
Name: apiToken.Name,
Token: token,
Scopes: req.Scopes,
CreatedAt: apiToken.CreatedAt.Format(time.RFC3339),
}Also update ListAPITokens to include scopes in each response item. In the loop body, add:
resp.Scopes = t.ScopesStep 1: Apply all changes above.
Step 2: Build check
go build ./internal/controlplane/api/...Step 3: Commit
git add internal/controlplane/api/api_token_handler.go
git commit -m "feat(api): accept and return scopes in API token endpoints"Task 7: Update agent config to support SHARDLYN_AGENT_ORGANIZATION_ID
Files:
- Modify:
internal/agent/config.go:17-19
Current identity fields:
// Agent identity
Name string `env:"SHARDLYN_AGENT_NAME"`
Token string `env:"SHARDLYN_AGENT_TOKEN"` // Bootstrap token for registration
AuthFile string `env:"SHARDLYN_AGENT_AUTH_FILE"` // File to store auth credentialsAdd OrganizationID:
// Agent identity
Name string `env:"SHARDLYN_AGENT_NAME"`
Token string `env:"SHARDLYN_AGENT_TOKEN"` // Bootstrap token for registration
OrganizationID string `env:"SHARDLYN_AGENT_ORGANIZATION_ID"` // Required when Token is an API token
AuthFile string `env:"SHARDLYN_AGENT_AUTH_FILE"` // File to store auth credentialsStep 1: Apply the change.
Step 2: Build check
go build ./internal/agent/...Step 3: Commit
git add internal/agent/config.go
git commit -m "feat(agent): add SHARDLYN_AGENT_ORGANIZATION_ID config field"Task 8: Update agent register() to send organization_id
Files:
- Modify:
internal/agent/agent.go:297-311
Current request map (agent.go:297-311):
req := map[string]interface{}{
"token": a.config.Token,
"hostname": hostname,
"name": a.config.Name,
"version": a.version,
"resources": map[string]interface{}{
"cpu_cores": cpuCores,
"memory_mb": memInfo.Total / 1024 / 1024,
"disk_gb": diskInfo.Total / 1024 / 1024 / 1024,
},
}Change to include organization_id when set:
req := map[string]interface{}{
"token": a.config.Token,
"hostname": hostname,
"name": a.config.Name,
"version": a.version,
"resources": map[string]interface{}{
"cpu_cores": cpuCores,
"memory_mb": memInfo.Total / 1024 / 1024,
"disk_gb": diskInfo.Total / 1024 / 1024 / 1024,
},
}
if a.config.OrganizationID != "" {
req["organization_id"] = a.config.OrganizationID
}Step 1: Apply the change.
Step 2: Build check
go build ./internal/agent/...Step 3: Commit
git add internal/agent/agent.go
git commit -m "feat(agent): send organization_id in registration request when configured"Task 9: Update RegisterRequest to accept organization_id
Files:
- Modify:
internal/controlplane/api/agent_handler.go:156-170
Current RegisterRequest struct:
type RegisterRequest struct {
Token string `json:"token"`
Hostname string `json:"hostname"`
Name string `json:"name"`
Version string `json:"version,omitempty"`
Resources struct {
CPUCores int `json:"cpu_cores"`
MemoryMB int64 `json:"memory_mb"`
DiskGB int `json:"disk_gb"`
} `json:"resources"`
Cloud *RegisterCloudInfo `json:"cloud,omitempty"`
}Add OrganizationID field:
type RegisterRequest struct {
Token string `json:"token"`
OrganizationID string `json:"organization_id,omitempty"`
Hostname string `json:"hostname"`
Name string `json:"name"`
Version string `json:"version,omitempty"`
Resources struct {
CPUCores int `json:"cpu_cores"`
MemoryMB int64 `json:"memory_mb"`
DiskGB int `json:"disk_gb"`
} `json:"resources"`
Cloud *RegisterCloudInfo `json:"cloud,omitempty"`
}Step 1: Apply the change.
Step 2: Build check
go build ./internal/controlplane/api/...Step 3: Commit
git add internal/controlplane/api/agent_handler.go
git commit -m "feat(api): add organization_id field to RegisterRequest"Task 10: Add CreateNodeForOrg to NodeRepo
Files:
- Modify:
internal/controlplane/store/node_repo.go(add afterRegisterAgent)
This is needed for the API token registration path, where there is no node_token to consume. The node is created directly in the specified org.
Step 1: Write the new method
Add after RegisterAgent (around line 813):
// CreateNodeForOrg creates a new node in the specified organization directly.
// Used when registering via an API token instead of a one-time node token.
func (r *NodeRepo) CreateNodeForOrg(ctx context.Context, orgID uuid.UUID, node *Node) error {
labelsJSON, _ := json.Marshal(node.Labels)
annotationsJSON, _ := json.Marshal(node.Annotations)
if node.ID == uuid.Nil {
node.ID = uuid.New()
}
node.OrganizationID = &orgID
query := `
INSERT INTO nodes (id, organization_id, name, hostname, status, agent_version, agent_token_hash,
cordoned, drain_requested, cpu_cores, memory_mb, disk_gb, labels, annotations)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
`
_, err := r.db.Exec(ctx, query,
node.ID,
node.OrganizationID,
node.Name,
node.Hostname,
node.Status,
node.AgentVersion,
node.AgentTokenHash,
node.Cordoned,
node.DrainRequested,
node.CPUCores,
node.MemoryMB,
node.DiskGB,
labelsJSON,
annotationsJSON,
)
if err != nil {
return fmt.Errorf("failed to create node for org: %w", err)
}
return nil
}Step 2: Build check
go build ./internal/controlplane/store/...Step 3: Commit
git add internal/controlplane/store/node_repo.go
git commit -m "feat(store): add CreateNodeForOrg for API token agent registration"Task 11: Implement API token path in Register handler
Files:
- Modify:
internal/controlplane/api/agent_handler.go—Registermethod (~line 186)
This is the core logic change. Before calling nodeRepo.RegisterAgent, check if the token is an API token (prefix shardlyn_api_). If so:
- Validate it via
apiTokenRepo.GetAPITokenByHash - Check it has
nodes.createscope (non-empty scopes list must include it) - Parse
organization_idfrom request (required) - Call
nodeRepo.CreateNodeForOrginstead
The AgentHandler needs access to apiTokenRepo. Check how it's created in di/container.go or wherever handlers are wired up — you'll need to add the field and a setter or update the constructor.
11a: Add apiTokenRepo to AgentHandler
Find AgentHandler struct definition (around line 1-30 of agent_handler.go). Add the field:
type AgentHandler struct {
nodeRepo *store.NodeRepo
provisionRepo *store.ProvisionRepo
apiTokenRepo *store.APITokenRepo // for API token registration path
logger *zap.Logger
}Add a setter (follow existing patterns for optional repos):
// SetAPITokenRepo configures the API token repo for the API token registration path.
func (h *AgentHandler) SetAPITokenRepo(repo *store.APITokenRepo) {
h.apiTokenRepo = repo
}11b: Wire it up in DI container
Find where AgentHandler is created (search for NewAgentHandler in internal/controlplane/di/container.go or similar). Add:
agentHandler.SetAPITokenRepo(apiTokenRepo)11c: Implement the detection logic in Register
Current flow (line 198-249):
tokenHash := auth.HashToken(req.Token)
// Generate auth token for the agent
agentAuthToken, err := auth.GenerateToken(32)
...
// Atomic registration
nodeID, err := h.nodeRepo.RegisterAgent(r.Context(), tokenHash, node)
if err != nil {
if errors.Is(err, store.ErrNodeTokenNotFound) || errors.Is(err, store.ErrNodeTokenUsed) || errors.Is(err, store.ErrNodeTokenExpired) {
RespondWithAPIError(w, NewAPIError(ErrCodeUnauthorized, err.Error()))
return
}
...
}Replace the RegisterAgent call and its error handling with:
// Try API token path first (token has shardlyn_api_ prefix)
if strings.HasPrefix(req.Token, "shardlyn_api_") && h.apiTokenRepo != nil {
tokenHash := auth.HashToken(req.Token)
apiToken, err := h.apiTokenRepo.GetAPITokenByHash(r.Context(), tokenHash)
if err != nil {
RespondWithAPIError(w, NewAPIError(ErrCodeUnauthorized, "invalid token"))
return
}
if apiToken.ExpiresAt != nil && time.Now().After(*apiToken.ExpiresAt) {
RespondWithAPIError(w, NewAPIError(ErrCodeUnauthorized, "token expired"))
return
}
// Check nodes.create scope: non-empty scopes must include it
hasScope := len(apiToken.Scopes) == 0
for _, s := range apiToken.Scopes {
if s == "nodes.create" {
hasScope = true
break
}
}
if !hasScope {
RespondWithAPIError(w, NewAPIError(ErrCodeForbidden, "token does not have nodes.create scope"))
return
}
if req.OrganizationID == "" {
RespondWithAPIError(w, ValidationError("organization_id is required when using an API token", nil))
return
}
orgID, err := uuid.Parse(req.OrganizationID)
if err != nil {
RespondWithAPIError(w, ValidationError("invalid organization_id", nil))
return
}
if err := h.nodeRepo.CreateNodeForOrg(r.Context(), orgID, node); err != nil {
h.logger.Error("failed to create node for org", zap.Error(err))
RespondWithAPIError(w, NewAPIError(ErrCodeInternal, "failed to register agent"))
return
}
nodeID := node.ID
// Continue to response below...
respondOK(w, RegisterResponse{
AgentID: nodeID.String(),
AuthToken: agentAuthToken,
})
return
}
// Original node token path
tokenHash := auth.HashToken(req.Token)
nodeID, err := h.nodeRepo.RegisterAgent(r.Context(), tokenHash, node)
if err != nil {
if errors.Is(err, store.ErrNodeTokenNotFound) || errors.Is(err, store.ErrNodeTokenUsed) || errors.Is(err, store.ErrNodeTokenExpired) {
RespondWithAPIError(w, NewAPIError(ErrCodeUnauthorized, err.Error()))
return
}
h.logger.Error("failed to register agent", zap.Error(err))
RespondWithAPIError(w, NewAPIError(ErrCodeInternal, "failed to register agent"))
return
}Note: The existing code after the RegisterAgent call (provision linking, label enrichment) should remain for the node token path only.
You'll need to add "strings" and "time" to the imports if not already present.
Step 1: Apply all changes in 11a, 11b, 11c.
Step 2: Build check
go build ./internal/controlplane/...Step 3: Run tests
go test ./internal/controlplane/... -v 2>&1 | tail -30Expected: all tests pass.
Step 4: Commit
git add internal/controlplane/api/agent_handler.go \
internal/controlplane/di/container.go
git commit -m "feat(api): accept API token as agent registration token with nodes.create scope"Task 12: Full build and test verification
Step 1: Build all packages
go build ./...Expected: no errors.
Step 2: Run all tests
go test ./... 2>&1 | grep -E "^(ok|FAIL|---)" | head -50Expected: all ok, no FAIL.
Step 3: Verify the feature end-to-end (manual)
Create an API token with
nodes.createscope:bashcurl -X POST http://localhost:8080/v1/api-tokens \ -H "Authorization: Bearer <user-jwt>" \ -H "Content-Type: application/json" \ -d '{"name":"ci-agent-token","scopes":["nodes.create"]}'Expected response includes
"scopes":["nodes.create"]and atokenstarting withshardlyn_api_.Create an API token WITHOUT
nodes.createscope:bashcurl -X POST http://localhost:8080/v1/api-tokens \ -H "Authorization: Bearer <user-jwt>" \ -H "Content-Type: application/json" \ -d '{"name":"read-only-token","scopes":["instances.read"]}'Try registering an agent with the read-only token:
bashcurl -X POST http://localhost:8080/v1/agents/register \ -H "Content-Type: application/json" \ -d '{"token":"<read-only-token>","organization_id":"<org-uuid>","hostname":"test","resources":{"cpu_cores":2,"memory_mb":4096,"disk_gb":50}}'Expected:
403 Forbiddenwith "token does not have nodes.create scope".Register with the
nodes.createtoken:bashcurl -X POST http://localhost:8080/v1/agents/register \ -H "Content-Type: application/json" \ -d '{"token":"<ci-agent-token>","organization_id":"<org-uuid>","hostname":"test","resources":{"cpu_cores":2,"memory_mb":4096,"disk_gb":50}}'Expected:
200 OKwithagent_idandauth_token.Register a second agent with the same token (reuse test): Same command as step 4 with different hostname. Expected:
200 OK(not consumed).Set
SHARDLYN_AGENT_TOKEN=<ci-agent-token>andSHARDLYN_AGENT_ORGANIZATION_ID=<org-uuid>in agent env, start agent. Expected: agent registers successfully.Verify existing
node_tokenflow still works by creating a node token and registering withoutorganization_idorshardlyn_api_prefix.
Summary of Files Changed
| File | Change |
|---|---|
migrations/postgres/000072_api_token_scopes.up.sql | Create |
migrations/postgres/000072_api_token_scopes.down.sql | Create |
migrations/sqlite/000073_api_token_scopes.up.sql | Create |
migrations/sqlite/000073_api_token_scopes.down.sql | Create |
internal/controlplane/store/models.go | Add Scopes types.StringArray to APIToken |
internal/controlplane/store/api_token_repo.go | Persist and read scopes in all 3 queries |
internal/controlplane/store/node_repo.go | Add CreateNodeForOrg method |
internal/controlplane/auth/middleware.go | Add APITokenScopes []string to UserContext, add HasAPITokenScope helper |
internal/controlplane/auth/api_token_validator.go | Populate APITokenScopes in returned UserContext |
internal/controlplane/auth/permission.go | Enforce scope restriction in HasPermission |
internal/controlplane/api/api_token_handler.go | Accept/validate/return scopes in create/list endpoints |
internal/controlplane/api/agent_handler.go | Add apiTokenRepo field, API token detection branch in Register |
internal/controlplane/di/container.go | Wire apiTokenRepo into AgentHandler |
internal/agent/config.go | Add OrganizationID / SHARDLYN_AGENT_ORGANIZATION_ID |
internal/agent/agent.go | Send organization_id in register request when set |