diff --git a/platform-api/README.md b/platform-api/README.md index f8d146164..99d5842df 100644 --- a/platform-api/README.md +++ b/platform-api/README.md @@ -35,7 +35,7 @@ go run ./cmd/main.go curl -k -X POST https://localhost:9243/api/v1/organizations \ -H 'Content-Type: application/json' \ -H 'Authorization: Bearer ' \ - -d '{"handle":"acme","name":"ACME Corporation"}' + -d '{"id":"","handle":"acme","name":"ACME Corporation","region":"us-east-1"}' ``` **2. Create a Project** @@ -58,7 +58,9 @@ curl -k -X POST https://localhost:9243/api/v1/gateways \ -H 'Authorization: Bearer ' \ -d '{ "name": "prod-gateway-01", - "displayName": "Production Gateway 01" + "displayName": "Production Gateway 01", + "vhost": "localhost", + "functionalityType": "regular" }' ``` @@ -91,6 +93,23 @@ Response includes the gateway authentication token: } ``` +**List Gateway Tokens:** +```bash +curl -k -s https://localhost:9243/api/v1/gateways//tokens \ + -H 'Authorization: Bearer ' +``` + +Response: +```json +[ + { + "id": "7ed55286-66a4-43ae-9271-bd1ead475a55", + "status": "active", + "createdAt": "2025-10-21T15:12:57.60936197+05:30" + } +] +``` + **5. Connect Gateway to Platform (WebSocket)** Install wscat if not already installed: diff --git a/platform-api/src/internal/handler/gateway.go b/platform-api/src/internal/handler/gateway.go index 4be0544be..0ceaf7f95 100644 --- a/platform-api/src/internal/handler/gateway.go +++ b/platform-api/src/internal/handler/gateway.go @@ -283,6 +283,41 @@ func (h *GatewayHandler) DeleteGateway(c *gin.Context) { c.Status(http.StatusNoContent) } +// ListTokens handles GET /api/v1/gateways/:gatewayId/tokens +func (h *GatewayHandler) ListTokens(c *gin.Context) { + orgId, exists := middleware.GetOrganizationFromContext(c) + if !exists { + c.JSON(http.StatusUnauthorized, utils.NewErrorResponse(401, "Unauthorized", + "Organization claim not found in token")) + return + } + + gatewayId := c.Param("gatewayId") + if gatewayId == "" { + c.JSON(http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", + "Gateway ID is required")) + return + } + + tokens, err := h.gatewayService.ListTokens(gatewayId, orgId) + if err != nil { + errMsg := err.Error() + + if strings.Contains(errMsg, "gateway not found") { + utils.LogError("Gateway not found during token listing", err) + c.JSON(http.StatusNotFound, utils.NewErrorResponse(404, "Not Found", errMsg)) + return + } + + utils.LogError("Failed to list tokens", err) + c.JSON(http.StatusInternalServerError, utils.NewErrorResponse(500, "Internal Server Error", + "Failed to list tokens")) + return + } + + c.JSON(http.StatusOK, tokens) +} + // RotateToken handles POST /api/v1/gateways/:gatewayId/tokens func (h *GatewayHandler) RotateToken(c *gin.Context) { orgId, exists := middleware.GetOrganizationFromContext(c) @@ -328,6 +363,48 @@ func (h *GatewayHandler) RotateToken(c *gin.Context) { c.JSON(http.StatusCreated, response) } +// RevokeToken handles DELETE /api/v1/gateways/:gatewayId/tokens/:tokenId +func (h *GatewayHandler) RevokeToken(c *gin.Context) { + orgId, exists := middleware.GetOrganizationFromContext(c) + if !exists { + c.JSON(http.StatusUnauthorized, utils.NewErrorResponse(401, "Unauthorized", + "Organization claim not found in token")) + return + } + + gatewayId := c.Param("gatewayId") + if gatewayId == "" { + c.JSON(http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", + "Gateway ID is required")) + return + } + + tokenId := c.Param("tokenId") + if tokenId == "" { + c.JSON(http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", + "Token ID is required")) + return + } + + err := h.gatewayService.RevokeToken(gatewayId, tokenId, orgId) + if err != nil { + errMsg := err.Error() + + if strings.Contains(errMsg, "not found") { + utils.LogError("Resource not found during token revocation", err) + c.JSON(http.StatusNotFound, utils.NewErrorResponse(404, "Not Found", errMsg)) + return + } + + utils.LogError("Failed to revoke token", err) + c.JSON(http.StatusInternalServerError, utils.NewErrorResponse(500, "Internal Server Error", + "Failed to revoke token")) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "Token revoked successfully"}) +} + // GetGatewayArtifacts handles GET /api/v1/gateways/{gatewayId}/live-proxy-artifacts func (h *GatewayHandler) GetGatewayArtifacts(c *gin.Context) { orgId, exists := middleware.GetOrganizationFromContext(c) @@ -382,7 +459,9 @@ func (h *GatewayHandler) RegisterRoutes(r *gin.Engine) { gatewayGroup.GET("/:gatewayId", h.GetGateway) gatewayGroup.PUT("/:gatewayId", h.UpdateGateway) gatewayGroup.DELETE("/:gatewayId", h.DeleteGateway) + gatewayGroup.GET("/:gatewayId/tokens", h.ListTokens) gatewayGroup.POST("/:gatewayId/tokens", h.RotateToken) + gatewayGroup.DELETE("/:gatewayId/tokens/:tokenId", h.RevokeToken) gatewayGroup.GET("/:gatewayId/live-proxy-artifacts", h.GetGatewayArtifacts) } diff --git a/platform-api/src/internal/service/gateway.go b/platform-api/src/internal/service/gateway.go index 14348f5cd..213f22b84 100644 --- a/platform-api/src/internal/service/gateway.go +++ b/platform-api/src/internal/service/gateway.go @@ -97,44 +97,12 @@ func (s *GatewayService) RegisterGateway(orgID, name, displayName, description, UpdatedAt: time.Now(), } - // 6. Generate plain-text token and salt - plainToken, err := generateToken() - if err != nil { - return nil, fmt.Errorf("failed to generate token: %w", err) - } - - saltBytes, err := generateSalt() - if err != nil { - return nil, fmt.Errorf("failed to generate salt: %w", err) - } - - // 7. Hash token with salt - tokenHash := hashToken(plainToken, saltBytes) - saltHex := hex.EncodeToString(saltBytes) - - // 8. Create GatewayToken model - tokenId := uuid.New().String() - gatewayToken := &model.GatewayToken{ - ID: tokenId, - GatewayID: gatewayId, - TokenHash: tokenHash, - Salt: saltHex, - Status: "active", - CreatedAt: time.Now(), - RevokedAt: nil, - } - - // 9. Insert gateway and token (in sequence - repository handles this) + // 6. Insert gateway if err := s.gatewayRepo.Create(gateway); err != nil { return nil, fmt.Errorf("failed to create gateway: %w", err) } - if err := s.gatewayRepo.CreateToken(gatewayToken); err != nil { - // Note: In production, this should be wrapped in a transaction - return nil, fmt.Errorf("failed to create token: %w", err) - } - - // 10. Return GatewayResponse with gateway details + // 7. Return GatewayResponse with gateway details response := &dto.GatewayResponse{ ID: gateway.ID, OrganizationID: gateway.OrganizationID, @@ -350,6 +318,37 @@ func (s *GatewayService) VerifyToken(plainToken string) (*model.Gateway, error) return nil, errors.New("invalid token") } +// ListTokens retrieves all active tokens for a gateway +func (s *GatewayService) ListTokens(gatewayId, orgId string) ([]dto.TokenInfoResponse, error) { + gateway, err := s.gatewayRepo.GetByUUID(gatewayId) + if err != nil { + return nil, fmt.Errorf("failed to query gateway: %w", err) + } + if gateway == nil { + return nil, errors.New("gateway not found") + } + if gateway.OrganizationID != orgId { + return nil, errors.New("gateway not found") + } + + activeTokens, err := s.gatewayRepo.GetActiveTokensByGatewayUUID(gatewayId) + if err != nil { + return nil, fmt.Errorf("failed to get tokens: %w", err) + } + + tokens := make([]dto.TokenInfoResponse, 0, len(activeTokens)) + for _, t := range activeTokens { + tokens = append(tokens, dto.TokenInfoResponse{ + ID: t.ID, + Status: t.Status, + CreatedAt: t.CreatedAt, + RevokedAt: t.RevokedAt, + }) + } + + return tokens, nil +} + // RotateToken generates a new token for a gateway (max 2 active tokens) func (s *GatewayService) RotateToken(gatewayId, orgId string) (*dto.TokenRotationResponse, error) { // 1. Validate gateway exists @@ -418,6 +417,37 @@ func (s *GatewayService) RotateToken(gatewayId, orgId string) (*dto.TokenRotatio return response, nil } +// RevokeToken revokes a specific token for a gateway +func (s *GatewayService) RevokeToken(gatewayId, tokenId, orgId string) error { + gateway, err := s.gatewayRepo.GetByUUID(gatewayId) + if err != nil { + return fmt.Errorf("failed to query gateway: %w", err) + } + if gateway == nil { + return errors.New("gateway not found") + } + if gateway.OrganizationID != orgId { + return errors.New("gateway not found") + } + + token, err := s.gatewayRepo.GetTokenByUUID(tokenId) + if err != nil { + return fmt.Errorf("failed to query token: %w", err) + } + if token == nil { + return errors.New("token not found") + } + if token.GatewayID != gatewayId { + return errors.New("token not found") + } + + if err := s.gatewayRepo.RevokeToken(tokenId); err != nil { + return fmt.Errorf("failed to revoke token: %w", err) + } + + return nil +} + // GetGatewayStatus retrieves gateway status information for polling func (s *GatewayService) GetGatewayStatus(orgID string, gatewayId *string) (*dto.GatewayStatusListResponse, error) { // Validate organizationId is provided and valid diff --git a/platform-api/src/internal/service/gateway_properties_test.go b/platform-api/src/internal/service/gateway_properties_test.go index 669470382..b5f484c8b 100644 --- a/platform-api/src/internal/service/gateway_properties_test.go +++ b/platform-api/src/internal/service/gateway_properties_test.go @@ -122,10 +122,6 @@ func TestRegisterGatewayProperties(t *testing.T) { if !reflect.DeepEqual(mockGatewayRepo.createdGateway.Properties, properties) { t.Errorf("Create() gateway properties = %v, want %v", mockGatewayRepo.createdGateway.Properties, properties) } - - if mockGatewayRepo.createdToken == nil { - t.Fatalf("CreateToken() was not called") - } } func TestUpdateGatewayProperties(t *testing.T) { diff --git a/platform-api/src/resources/openapi.yaml b/platform-api/src/resources/openapi.yaml index 4ee078848..62dc7a0ea 100644 --- a/platform-api/src/resources/openapi.yaml +++ b/platform-api/src/resources/openapi.yaml @@ -1177,6 +1177,31 @@ paths: $ref: '#/components/responses/InternalServerError' /gateways/{gatewayId}/tokens: + get: + summary: List active gateway tokens + description: | + Returns all active tokens for the specified gateway. Token hashes and salts are never exposed. + Access is validated against the organization in the JWT token. + operationId: listGatewayTokens + tags: + - Gateway Tokens + parameters: + - $ref: '#/components/parameters/GatewayID' + responses: + '200': + description: List of active tokens + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/TokenInfoResponse' + '401': + $ref: '#/components/responses/Unauthorized' + '404': + $ref: '#/components/responses/NotFound' + '500': + $ref: '#/components/responses/InternalServerError' post: summary: Rotate gateway token description: |