Skip to content

Commit 6cd8e54

Browse files
committed
feat(api): adds initial auth to endpoints
1 parent 760bdc0 commit 6cd8e54

File tree

12 files changed

+250
-76
lines changed

12 files changed

+250
-76
lines changed

foundry/api/Earthfile

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,8 @@ test:
5757
--load foundry-api-test:latest=(+docker-test) \
5858
--compose docker-compose.yml \
5959
--service api \
60+
--service auth \
61+
--service auth-jwt \
6062
--service postgres
6163
RUN docker compose up api-test
6264
END

foundry/api/client/client.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ type Client interface {
4343
type HTTPClient struct {
4444
baseURL string
4545
httpClient *http.Client
46+
token string
4647
}
4748

4849
// ClientOption is a function type for client configuration
@@ -62,6 +63,13 @@ func WithTransport(transport http.RoundTripper) ClientOption {
6263
}
6364
}
6465

66+
// WithToken sets the JWT token for authentication
67+
func WithToken(token string) ClientOption {
68+
return func(c *HTTPClient) {
69+
c.token = token
70+
}
71+
}
72+
6573
// NewClient creates a new API client
6674
func NewClient(baseURL string, options ...ClientOption) Client {
6775
client := &HTTPClient{
@@ -101,6 +109,11 @@ func (c *HTTPClient) do(ctx context.Context, method, path string, reqBody, respB
101109
}
102110
req.Header.Set("Accept", "application/json")
103111

112+
// Add JWT token to Authorization header if present
113+
if c.token != "" {
114+
req.Header.Set("Authorization", "Bearer "+c.token)
115+
}
116+
104117
resp, err := c.httpClient.Do(req)
105118
if err != nil {
106119
return fmt.Errorf("error performing request: %w", err)

foundry/api/cmd/api/main.go

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ import (
1313
"github.com/alecthomas/kong"
1414
"github.com/input-output-hk/catalyst-forge/foundry/api/cmd/api/auth"
1515
"github.com/input-output-hk/catalyst-forge/foundry/api/internal/api"
16+
"github.com/input-output-hk/catalyst-forge/foundry/api/internal/api/middleware"
17+
am "github.com/input-output-hk/catalyst-forge/foundry/api/internal/auth"
1618
"github.com/input-output-hk/catalyst-forge/foundry/api/internal/config"
1719
"github.com/input-output-hk/catalyst-forge/foundry/api/internal/models"
1820
"github.com/input-output-hk/catalyst-forge/foundry/api/internal/repository"
@@ -111,8 +113,16 @@ func (r *RunCmd) Run() error {
111113
releaseService := service.NewReleaseService(releaseRepo, aliasRepo, counterRepo, deploymentRepo)
112114
deploymentService := service.NewDeploymentService(deploymentRepo, releaseRepo, eventRepo, k8sClient, db, logger)
113115

116+
// Initialize middleware
117+
authManager, err := am.NewAuthManager(r.Auth.PrivateKey, r.Auth.PublicKey, am.WithLogger(logger))
118+
if err != nil {
119+
logger.Error("Failed to initialize auth manager", "error", err)
120+
return err
121+
}
122+
authMiddleware := middleware.NewAuthMiddleware(authManager, logger)
123+
114124
// Setup router
115-
router := api.SetupRouter(releaseService, deploymentService, db, logger)
125+
router := api.SetupRouter(releaseService, deploymentService, authMiddleware, db, logger)
116126

117127
// Initialize server
118128
server := api.NewServer(r.GetServerAddr(), router, logger)

foundry/api/docker-compose.yml

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,31 @@
11
services:
2+
auth:
3+
image: foundry-api:latest
4+
container_name: auth
5+
entrypoint: ["/bin/sh", "-c", "/app/foundry-api auth init --output-dir /data"]
6+
volumes:
7+
- auth:/data
8+
restart: on-failure
9+
10+
auth-jwt:
11+
image: foundry-api:latest
12+
container_name: auth-jwt
13+
entrypoint: ["/bin/sh", "-c", "/app/foundry-api auth generate -a -k /data/private.pem >/jwt/token.txt"]
14+
volumes:
15+
- auth:/data
16+
- jwt:/jwt
17+
restart: on-failure
18+
depends_on:
19+
auth:
20+
condition: service_started
21+
222
api:
323
image: foundry-api:latest
424
container_name: api
525
environment:
626
HTTP_PORT: 5000
27+
AUTH_PRIVATE_KEY: /auth/private.pem
28+
AUTH_PUBLIC_KEY: /auth/public.pem
729
DB_SUPER_USER: postgres
830
DB_SUPER_PASSWORD: postgres
931
DB_ROOT_NAME: postgres
@@ -19,13 +41,17 @@ services:
1941
LOG_FORMAT: text
2042
ports:
2143
- "5000:5000"
44+
volumes:
45+
- auth:/auth
2246
healthcheck:
2347
test: ["CMD", "curl", "-f", "http://localhost:5000/healthz"]
2448
interval: 10s
2549
timeout: 5s
2650
retries: 3
2751
start_period: 10s
2852
depends_on:
53+
auth:
54+
condition: service_started
2955
postgres:
3056
condition: service_healthy
3157
restart: on-failure
@@ -35,6 +61,9 @@ services:
3561
container_name: api-test
3662
environment:
3763
API_URL: http://api:5000
64+
JWT_TOKEN_PATH: /jwt/token.txt
65+
volumes:
66+
- jwt:/jwt
3867
depends_on:
3968
api:
4069
condition: service_healthy
@@ -58,4 +87,6 @@ services:
5887
restart: on-failure
5988

6089
volumes:
90+
auth:
91+
jwt:
6192
postgres-data:
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
package middleware
2+
3+
import (
4+
"fmt"
5+
"log/slog"
6+
"net/http"
7+
"slices"
8+
"strings"
9+
10+
"github.com/gin-gonic/gin"
11+
"github.com/input-output-hk/catalyst-forge/foundry/api/internal/auth"
12+
)
13+
14+
// AuthenticatedUser is a struct that contains the user information from the
15+
// authentication middleware
16+
type AuthenticatedUser struct {
17+
ID string
18+
Permissions []auth.Permission
19+
Claims *auth.Claims
20+
}
21+
22+
// hasPermissions checks if the user has the required permissions
23+
func (u *AuthenticatedUser) hasPermissions(permissions []auth.Permission) bool {
24+
for _, required := range permissions {
25+
if slices.Contains(u.Permissions, required) {
26+
return true
27+
}
28+
}
29+
return false
30+
}
31+
32+
// AuthMiddleware provides a middleware that validates a user's permissions
33+
type AuthMiddleware struct {
34+
authManager *auth.AuthManager
35+
logger *slog.Logger
36+
}
37+
38+
// ValidatePermissions returns a middleware that validates a user's permissions
39+
func (h *AuthMiddleware) ValidatePermissions(permissions []auth.Permission) gin.HandlerFunc {
40+
return func(c *gin.Context) {
41+
token, err := h.getToken(c)
42+
if err != nil {
43+
h.logger.Warn("Invalid token", "error", err)
44+
c.JSON(http.StatusUnauthorized, gin.H{
45+
"error": "Invalid token",
46+
})
47+
c.Abort()
48+
}
49+
50+
user, err := h.getUser(token)
51+
if err != nil {
52+
h.logger.Warn("Invalid token", "error", err)
53+
c.JSON(http.StatusUnauthorized, gin.H{
54+
"error": "Invalid token",
55+
})
56+
c.Abort()
57+
}
58+
59+
if !user.hasPermissions(permissions) {
60+
h.logger.Warn("Permission denied", "user_id", user.ID, "permissions", permissions)
61+
c.JSON(http.StatusForbidden, gin.H{
62+
"error": "Permission denied",
63+
})
64+
c.Abort()
65+
}
66+
67+
c.Set("user", user)
68+
c.Next()
69+
}
70+
}
71+
72+
// getToken extracts the token from the Authorization header
73+
func (h *AuthMiddleware) getToken(c *gin.Context) (string, error) {
74+
authHeader := c.GetHeader("Authorization")
75+
if authHeader == "" {
76+
return "", fmt.Errorf("authorization header is required")
77+
}
78+
79+
if !strings.HasPrefix(authHeader, "Bearer ") {
80+
return "", fmt.Errorf("authorization header must start with 'Bearer '")
81+
}
82+
83+
return strings.TrimPrefix(authHeader, "Bearer "), nil
84+
}
85+
86+
// getUser validates the token and returns the authenticated user
87+
func (h *AuthMiddleware) getUser(token string) (*AuthenticatedUser, error) {
88+
claims, err := h.authManager.ValidateToken(token)
89+
if err != nil {
90+
return nil, err
91+
}
92+
93+
return &AuthenticatedUser{
94+
ID: claims.UserID,
95+
Permissions: claims.Permissions,
96+
Claims: claims,
97+
}, nil
98+
}
99+
100+
// NewAuthMiddleware creates a new AuthMiddlewareHandler
101+
func NewAuthMiddleware(authManager *auth.AuthManager, logger *slog.Logger) *AuthMiddleware {
102+
return &AuthMiddleware{
103+
authManager: authManager,
104+
logger: logger,
105+
}
106+
}

foundry/api/internal/api/router.go

Lines changed: 17 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"github.com/gin-gonic/gin"
77
"github.com/input-output-hk/catalyst-forge/foundry/api/internal/api/handlers"
88
"github.com/input-output-hk/catalyst-forge/foundry/api/internal/api/middleware"
9+
"github.com/input-output-hk/catalyst-forge/foundry/api/internal/auth"
910
"github.com/input-output-hk/catalyst-forge/foundry/api/internal/service"
1011
"gorm.io/gorm"
1112
)
@@ -14,6 +15,7 @@ import (
1415
func SetupRouter(
1516
releaseService service.ReleaseService,
1617
deploymentService service.DeploymentService,
18+
am *middleware.AuthMiddleware,
1719
db *gorm.DB,
1820
logger *slog.Logger,
1921
) *gin.Engine {
@@ -39,27 +41,27 @@ func SetupRouter(
3941
// Route Setup //
4042

4143
// Release endpoints
42-
r.POST("/release", releaseHandler.CreateRelease)
43-
r.GET("/release/:id", releaseHandler.GetRelease)
44-
r.PUT("/release/:id", releaseHandler.UpdateRelease)
45-
r.GET("/releases", releaseHandler.ListReleases)
44+
r.POST("/release", am.ValidatePermissions([]auth.Permission{auth.PermReleaseWrite}), releaseHandler.CreateRelease)
45+
r.GET("/release/:id", am.ValidatePermissions([]auth.Permission{auth.PermReleaseRead}), releaseHandler.GetRelease)
46+
r.PUT("/release/:id", am.ValidatePermissions([]auth.Permission{auth.PermReleaseWrite}), releaseHandler.UpdateRelease)
47+
r.GET("/releases", am.ValidatePermissions([]auth.Permission{auth.PermReleaseRead}), releaseHandler.ListReleases)
4648

4749
// Release aliases
48-
r.GET("/release/alias/:name", releaseHandler.GetReleaseByAlias)
49-
r.POST("/release/alias/:name", releaseHandler.CreateAlias)
50-
r.DELETE("/release/alias/:name", releaseHandler.DeleteAlias)
51-
r.GET("/release/:id/aliases", releaseHandler.ListAliases)
50+
r.GET("/release/alias/:name", am.ValidatePermissions([]auth.Permission{auth.PermReleaseRead}), releaseHandler.GetReleaseByAlias)
51+
r.POST("/release/alias/:name", am.ValidatePermissions([]auth.Permission{auth.PermReleaseWrite}), releaseHandler.CreateAlias)
52+
r.DELETE("/release/alias/:name", am.ValidatePermissions([]auth.Permission{auth.PermReleaseWrite}), releaseHandler.DeleteAlias)
53+
r.GET("/release/:id/aliases", am.ValidatePermissions([]auth.Permission{auth.PermReleaseRead}), releaseHandler.ListAliases)
5254

5355
// Deployment endpoints
54-
r.POST("/release/:id/deploy", deploymentHandler.CreateDeployment)
55-
r.GET("/release/:id/deploy/:deployId", deploymentHandler.GetDeployment)
56-
r.PUT("/release/:id/deploy/:deployId", deploymentHandler.UpdateDeployment)
57-
r.GET("/release/:id/deployments", deploymentHandler.ListDeployments)
58-
r.GET("/release/:id/deploy/latest", deploymentHandler.GetLatestDeployment)
56+
r.POST("/release/:id/deploy", am.ValidatePermissions([]auth.Permission{auth.PermDeploymentWrite}), deploymentHandler.CreateDeployment)
57+
r.GET("/release/:id/deploy/:deployId", am.ValidatePermissions([]auth.Permission{auth.PermDeploymentRead}), deploymentHandler.GetDeployment)
58+
r.PUT("/release/:id/deploy/:deployId", am.ValidatePermissions([]auth.Permission{auth.PermDeploymentWrite}), deploymentHandler.UpdateDeployment)
59+
r.GET("/release/:id/deployments", am.ValidatePermissions([]auth.Permission{auth.PermDeploymentRead}), deploymentHandler.ListDeployments)
60+
r.GET("/release/:id/deploy/latest", am.ValidatePermissions([]auth.Permission{auth.PermDeploymentRead}), deploymentHandler.GetLatestDeployment)
5961

6062
// Deployment event endpoints
61-
r.POST("/release/:id/deploy/:deployId/events", deploymentHandler.AddDeploymentEvent)
62-
r.GET("/release/:id/deploy/:deployId/events", deploymentHandler.GetDeploymentEvents)
63+
r.POST("/release/:id/deploy/:deployId/events", am.ValidatePermissions([]auth.Permission{auth.PermDeploymentEventWrite}), deploymentHandler.AddDeploymentEvent)
64+
r.GET("/release/:id/deploy/:deployId/events", am.ValidatePermissions([]auth.Permission{auth.PermDeploymentEventRead}), deploymentHandler.GetDeploymentEvents)
6365

6466
return r
6567
}

foundry/api/internal/config/config.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import (
1111
// Config represents the application configuration
1212
type Config struct {
1313
Server ServerConfig `kong:"embed"`
14+
Auth AuthConfig `kong:"embed"`
1415
Database DatabaseConfig `kong:"embed"`
1516
Logging LoggingConfig `kong:"embed"`
1617
Kubernetes KubernetesConfig `kong:"embed"`
@@ -22,6 +23,12 @@ type ServerConfig struct {
2223
Timeout time.Duration `kong:"help='Server timeout',default=30s,env='SERVER_TIMEOUT'"`
2324
}
2425

26+
// AuthConfig represents authentication-specific configuration
27+
type AuthConfig struct {
28+
PrivateKey string `kong:"help='Path to private key for JWT authentication',env='AUTH_PRIVATE_KEY'"`
29+
PublicKey string `kong:"help='Path to public key for JWT authentication',env='AUTH_PUBLIC_KEY'"`
30+
}
31+
2532
// DatabaseConfig represents database-specific configuration
2633
type DatabaseConfig struct {
2734
Host string `kong:"help='Database host',default='localhost',env='DB_HOST'"`

foundry/api/test/alias_test.go

Lines changed: 2 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,8 @@
11
package test
22

33
import (
4-
"context"
54
"encoding/base64"
65
"fmt"
7-
"os"
86
"testing"
97
"time"
108

@@ -15,13 +13,8 @@ import (
1513
)
1614

1715
func TestAliasAPI(t *testing.T) {
18-
apiURL := os.Getenv("API_URL")
19-
if apiURL == "" {
20-
apiURL = "http://localhost:8080"
21-
}
22-
23-
c := client.NewClient(apiURL)
24-
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
16+
c := newTestClient()
17+
ctx, cancel := newTestContext()
2518
defer cancel()
2619

2720
projectName := fmt.Sprintf("test-project-alias-%d", time.Now().Unix())

0 commit comments

Comments
 (0)