diff --git a/server/internal/api/auth_providers.go b/server/internal/api/auth_providers.go index 67ccdc7..959f24c 100644 --- a/server/internal/api/auth_providers.go +++ b/server/internal/api/auth_providers.go @@ -26,10 +26,30 @@ func viewOf(c *gin.Context, p models.AuthProvider) authProviderView { } } +// listAuthPresets godoc +// +// @Summary List SSO presets +// @Description Preset providers (Entra, Google, Okta, GitHub) that expand to a real issuer on save. +// @Tags auth-providers +// @Produce json +// @Success 200 {array} auth.Preset +// @Security cookieAuth +// @Security bearerAuth +// @Router /auth/presets [get] func listAuthPresets(c *gin.Context) { c.JSON(http.StatusOK, auth.Presets()) } +// listAuthProviders godoc +// +// @Summary List SSO providers +// @Tags auth-providers +// @Produce json +// @Success 200 {array} authProviderView +// @Failure 500 {object} ErrorResponse +// @Security cookieAuth +// @Security bearerAuth +// @Router /auth/providers [get] func listAuthProviders(c *gin.Context) { providers, err := services.ListAuthProviders(auth.InstanceID(c)) if err != nil { @@ -43,6 +63,18 @@ func listAuthProviders(c *gin.Context) { c.JSON(http.StatusOK, out) } +// createAuthProvider godoc +// +// @Summary Create an SSO provider +// @Tags auth-providers +// @Accept json +// @Produce json +// @Param body body object{name=string,preset=string,issuer_input=string,client_id=string,client_secret=string,enabled=bool} true "Provider parameters" +// @Success 201 {object} authProviderView +// @Failure 400 {object} ErrorResponse +// @Security cookieAuth +// @Security bearerAuth +// @Router /auth/providers [post] func createAuthProvider(c *gin.Context) { var body struct { Name string `json:"name"` @@ -80,6 +112,22 @@ func createAuthProvider(c *gin.Context) { c.JSON(http.StatusCreated, viewOf(c, *p)) } +// updateAuthProvider godoc +// +// @Summary Update an SSO provider +// @Tags auth-providers +// @Accept json +// @Produce json +// @Param id path string true "Provider ID" +// @Param body body object{name=string,issuer_input=string,client_id=string,client_secret=string,enabled=bool,order=int} true "Fields to update" +// @Success 200 {object} SavedResponse +// @Failure 400 {object} ErrorResponse +// @Failure 404 {object} ErrorResponse +// @Failure 409 {object} ErrorResponse +// @Failure 500 {object} ErrorResponse +// @Security cookieAuth +// @Security bearerAuth +// @Router /auth/providers/{id} [put] func updateAuthProvider(c *gin.Context) { var body struct { Name *string `json:"name"` @@ -135,9 +183,23 @@ func updateAuthProvider(c *gin.Context) { // document was built from the old ones. auth.EvictProvider(providerID) services.LogEvent(instanceID, "auth_provider.update", actorFromCtx(c), "", "", existing.Name) - c.JSON(http.StatusOK, gin.H{"saved": true}) + c.JSON(http.StatusOK, SavedResponse{Saved: true}) } +// deleteAuthProvider godoc +// +// @Summary Delete an SSO provider +// @Description Refused when the instance would be left with no way in (no local login and no other enabled provider). +// @Tags auth-providers +// @Produce json +// @Param id path string true "Provider ID" +// @Success 200 {object} DeletedResponse +// @Failure 404 {object} ErrorResponse +// @Failure 409 {object} ErrorResponse +// @Failure 500 {object} ErrorResponse +// @Security cookieAuth +// @Security bearerAuth +// @Router /auth/providers/{id} [delete] func deleteAuthProvider(c *gin.Context) { instanceID := auth.InstanceID(c) providerID := c.Param("id") @@ -161,7 +223,7 @@ func deleteAuthProvider(c *gin.Context) { } auth.EvictProvider(providerID) services.LogEvent(instanceID, "auth_provider.delete", actorFromCtx(c), "", "", existing.Name) - c.JSON(http.StatusOK, gin.H{"deleted": true}) + c.JSON(http.StatusOK, DeletedResponse{Deleted: true}) } // guardProviderChange asks whether the instance would still have a way in. @@ -178,6 +240,18 @@ func guardProviderChange(instanceID string, existing *models.AuthProvider, enabl return services.CheckLockout(services.IsLocalLoginEnabled(instanceID), n-1) } +// ackAuthProviderNotice godoc +// +// @Summary Acknowledge a provider migration notice +// @Tags auth-providers +// @Produce json +// @Param id path string true "Provider ID" +// @Success 200 {object} AcknowledgedResponse +// @Failure 404 {object} ErrorResponse +// @Failure 500 {object} ErrorResponse +// @Security cookieAuth +// @Security bearerAuth +// @Router /auth/providers/{id}/ack-notice [post] func ackAuthProviderNotice(c *gin.Context) { instanceID := auth.InstanceID(c) providerID := c.Param("id") @@ -191,10 +265,21 @@ func ackAuthProviderNotice(c *gin.Context) { return } services.LogEvent(instanceID, "auth_provider.ack_notice", actorFromCtx(c), "", "", existing.Name) - c.JSON(http.StatusOK, gin.H{"acknowledged": true}) + c.JSON(http.StatusOK, AcknowledgedResponse{Acknowledged: true}) } -// testAuthProvider proves the configuration is reachable. It signs nobody in. +// testAuthProvider godoc +// +// @Summary Test an SSO provider's reachability +// @Description Proves the configuration is reachable. It signs nobody in. +// @Tags auth-providers +// @Produce json +// @Param id path string true "Provider ID" +// @Success 200 {object} TestProviderResponse +// @Failure 404 {object} ErrorResponse +// @Security cookieAuth +// @Security bearerAuth +// @Router /auth/providers/{id}/test [post] func testAuthProvider(c *gin.Context) { instanceID := auth.InstanceID(c) p, err := services.GetAuthProvider(instanceID, c.Param("id")) @@ -206,15 +291,15 @@ func testAuthProvider(c *gin.Context) { // GitHub has no discovery document. The only meaningful check without // a user token is that credentials are present. if p.ClientID == "" || p.ClientSecretEnc == "" { - c.JSON(http.StatusOK, gin.H{"ok": false, "message": "client ID and secret are required"}) + c.JSON(http.StatusOK, TestProviderResponse{OK: false, Message: "client ID and secret are required"}) return } - c.JSON(http.StatusOK, gin.H{"ok": true, "message": "credentials are configured"}) + c.JSON(http.StatusOK, TestProviderResponse{OK: true, Message: "credentials are configured"}) return } if _, err := oidc.NewProvider(c.Request.Context(), p.Issuer); err != nil { - c.JSON(http.StatusOK, gin.H{"ok": false, "message": err.Error()}) + c.JSON(http.StatusOK, TestProviderResponse{OK: false, Message: err.Error()}) return } - c.JSON(http.StatusOK, gin.H{"ok": true, "message": "discovery document fetched"}) + c.JSON(http.StatusOK, TestProviderResponse{OK: true, Message: "discovery document fetched"}) } diff --git a/server/internal/api/channels.go b/server/internal/api/channels.go index ad84611..b8062d7 100644 --- a/server/internal/api/channels.go +++ b/server/internal/api/channels.go @@ -18,6 +18,16 @@ func registerChannelRoutes(g *gin.RouterGroup) { g.POST("/channels/:id/test", testChannel) } +// listChannels godoc +// +// @Summary List notification channels +// @Tags channels +// @Produce json +// @Success 200 {array} models.NotificationChannel +// @Failure 500 {object} ErrorResponse +// @Security cookieAuth +// @Security bearerAuth +// @Router /channels [get] func listChannels(c *gin.Context) { channels, err := services.ListChannels(auth.InstanceID(c)) if err != nil { @@ -27,6 +37,20 @@ func listChannels(c *gin.Context) { c.JSON(http.StatusOK, channels) } +// createChannel godoc +// +// @Summary Create a notification channel +// @Tags channels +// @Accept json +// @Produce json +// @Param body body models.NotificationChannel true "Channel to create" +// @Success 201 {object} models.NotificationChannel +// @Failure 400 {object} ErrorResponse +// @Failure 403 {object} LimitExceededResponse +// @Failure 500 {object} ErrorResponse +// @Security cookieAuth +// @Security bearerAuth +// @Router /channels [post] func createChannel(c *gin.Context) { var ch models.NotificationChannel if err := c.ShouldBindJSON(&ch); err != nil { @@ -48,6 +72,20 @@ func createChannel(c *gin.Context) { c.JSON(http.StatusCreated, created) } +// updateChannel godoc +// +// @Summary Update a notification channel +// @Tags channels +// @Accept json +// @Produce json +// @Param id path string true "Channel ID" +// @Param body body object{name=string,type=string,config=map[string]string,enabled=bool} true "Fields to update" +// @Success 204 +// @Failure 400 {object} ErrorResponse +// @Failure 500 {object} ErrorResponse +// @Security cookieAuth +// @Security bearerAuth +// @Router /channels/{id} [put] func updateChannel(c *gin.Context) { var body struct { Name *string `json:"name"` @@ -83,6 +121,16 @@ func updateChannel(c *gin.Context) { c.Status(http.StatusNoContent) } +// deleteChannel godoc +// +// @Summary Delete a notification channel +// @Tags channels +// @Param id path string true "Channel ID" +// @Success 204 +// @Failure 500 {object} ErrorResponse +// @Security cookieAuth +// @Security bearerAuth +// @Router /channels/{id} [delete] func deleteChannel(c *gin.Context) { if err := services.DeleteChannel(auth.InstanceID(c), c.Param("id")); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) @@ -91,10 +139,21 @@ func deleteChannel(c *gin.Context) { c.Status(http.StatusNoContent) } +// testChannel godoc +// +// @Summary Send a test notification +// @Tags channels +// @Produce json +// @Param id path string true "Channel ID" +// @Success 200 {object} StatusResponse +// @Failure 502 {object} ErrorResponse +// @Security cookieAuth +// @Security bearerAuth +// @Router /channels/{id}/test [post] func testChannel(c *gin.Context) { if err := services.TestChannel(auth.InstanceID(c), c.Param("id")); err != nil { c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()}) return } - c.JSON(http.StatusOK, gin.H{"status": "sent"}) + c.JSON(http.StatusOK, StatusResponse{Status: "sent"}) } diff --git a/server/internal/api/console.go b/server/internal/api/console.go index 07c964a..656fb85 100644 --- a/server/internal/api/console.go +++ b/server/internal/api/console.go @@ -16,6 +16,22 @@ import ( "github.com/wwt/guac" ) +// consoleConnect godoc +// +// @Summary Open a browser console session +// @Description Mints a one-time session token for the /console/tunnel websocket. Requires a live agent — answers 409 agent_offline otherwise. +// @Tags console +// @Accept json +// @Produce json +// @Param body body object{server_id=string,protocol=string,key_id=string,rdp_username=string,rdp_password=string,ssh_username=string} true "Session parameters" +// @Success 200 {object} ConsoleConnectResponse +// @Failure 400 {object} ErrorResponse +// @Failure 404 {object} ErrorResponse +// @Failure 409 {object} ErrorResponse +// @Failure 500 {object} ErrorResponse +// @Security cookieAuth +// @Security bearerAuth +// @Router /console/connect [post] func consoleConnect(c *gin.Context) { var body struct { ServerID string `json:"server_id" binding:"required"` @@ -73,10 +89,10 @@ func consoleConnect(c *gin.Context) { services.LogEvent(auth.InstanceID(c), "console.opened", actorFromCtx(c), srv.ServerID, "", "console session opened ("+body.Protocol+", agent-relayed)") - c.JSON(http.StatusOK, gin.H{ - "session_id": sess.SessionID, - "token": token, - "ws_path": "/api/console/tunnel", + c.JSON(http.StatusOK, ConsoleConnectResponse{ + SessionID: sess.SessionID, + Token: token, + WSPath: "/api/console/tunnel", }) } @@ -100,6 +116,21 @@ func queryIntDefault(r *http.Request, key string, def int) int { // // Lines are prefixed with the session ID so one attempt can be followed across // pods, and the pod's own hostname so it is obvious which one served it. +// consoleTunnel godoc +// +// @Summary Console websocket tunnel +// @Description Upgrades the browser's connection to a websocket and joins it to guacd, relayed through the agent. Consumes the one-time session token from /console/connect. +// @Tags console +// @Param token query string true "One-time session token" +// @Success 101 +// @Failure 401 {object} ErrorResponse +// @Failure 403 {object} ErrorResponse +// @Failure 404 {object} ErrorResponse +// @Failure 409 {object} ErrorResponse +// @Failure 500 {object} ErrorResponse +// @Security cookieAuth +// @Security bearerAuth +// @Router /console/tunnel [get] func consoleTunnel(c *gin.Context) { host, _ := os.Hostname() diff --git a/server/internal/api/instance.go b/server/internal/api/instance.go index 87a1b8d..24dc3a5 100644 --- a/server/internal/api/instance.go +++ b/server/internal/api/instance.go @@ -10,6 +10,16 @@ import ( "github.com/gin-gonic/gin" ) +// listInstanceUsers godoc +// +// @Summary List instance members +// @Tags instance-users +// @Produce json +// @Success 200 {array} models.User +// @Failure 500 {object} ErrorResponse +// @Security cookieAuth +// @Security bearerAuth +// @Router /instance/users [get] func listInstanceUsers(c *gin.Context) { users, err := services.ListUsers(auth.InstanceID(c)) if err != nil { @@ -23,6 +33,20 @@ func actorMayGrantOwner(c *gin.Context) bool { return auth.Role(c) == models.RoleOwner } +// createInstanceUser godoc +// +// @Summary Create an instance member +// @Description Only an owner can create another owner. +// @Tags instance-users +// @Accept json +// @Produce json +// @Param body body object{email=string,password=string,role=string} true "New member" +// @Success 201 {object} models.User +// @Failure 400 {object} ErrorResponse +// @Failure 403 {object} ErrorResponse +// @Security cookieAuth +// @Security bearerAuth +// @Router /instance/users [post] func createInstanceUser(c *gin.Context) { var body struct { Email string `json:"email"` @@ -52,6 +76,24 @@ func createInstanceUser(c *gin.Context) { c.JSON(http.StatusCreated, u) } +// updateInstanceUserRole godoc +// +// @Summary Change an instance member's role +// @Description A caller cannot change their own role. Only an owner can change owner roles. +// @Tags instance-users +// @Accept json +// @Produce json +// @Param id path string true "User ID" +// @Param body body object{role=string} true "New role" +// @Success 200 {object} OKResponse +// @Failure 400 {object} ErrorResponse +// @Failure 403 {object} ErrorResponse +// @Failure 404 {object} ErrorResponse +// @Failure 409 {object} ErrorResponse +// @Failure 500 {object} ErrorResponse +// @Security cookieAuth +// @Security bearerAuth +// @Router /instance/users/{id}/role [put] func updateInstanceUserRole(c *gin.Context) { var body struct { Role string `json:"role"` @@ -84,9 +126,24 @@ func updateInstanceUserRole(c *gin.Context) { c.JSON(orgUserErrStatus(err), gin.H{"error": err.Error()}) return } - c.JSON(http.StatusOK, gin.H{"ok": true}) + c.JSON(http.StatusOK, OKResponse{OK: true}) } +// deleteInstanceUser godoc +// +// @Summary Remove an instance member +// @Description A caller cannot remove their own account. Only an owner can remove another owner. +// @Tags instance-users +// @Produce json +// @Param id path string true "User ID" +// @Success 200 {object} DeletedResponse +// @Failure 403 {object} ErrorResponse +// @Failure 404 {object} ErrorResponse +// @Failure 409 {object} ErrorResponse +// @Failure 500 {object} ErrorResponse +// @Security cookieAuth +// @Security bearerAuth +// @Router /instance/users/{id} [delete] func deleteInstanceUser(c *gin.Context) { instanceID, targetID := auth.InstanceID(c), c.Param("id") if targetID == auth.UserID(c) { @@ -107,7 +164,7 @@ func deleteInstanceUser(c *gin.Context) { c.JSON(orgUserErrStatus(err), gin.H{"error": err.Error()}) return } - c.JSON(http.StatusOK, gin.H{"deleted": true}) + c.JSON(http.StatusOK, DeletedResponse{Deleted: true}) } func orgUserErrStatus(err error) int { diff --git a/server/internal/api/licence.go b/server/internal/api/licence.go index 565408a..1f79474 100644 --- a/server/internal/api/licence.go +++ b/server/internal/api/licence.go @@ -116,6 +116,15 @@ type licenceUsageResponse struct { Channels int `json:"channels"` } +// getLicence godoc +// +// @Summary Get this instance's licence state +// @Tags licence +// @Produce json +// @Success 200 {object} licenceResponse +// @Security cookieAuth +// @Security bearerAuth +// @Router /license [get] func getLicence(c *gin.Context) { instanceID := auth.InstanceID(c) st := services.GetLicenseState(instanceID) @@ -169,6 +178,21 @@ func licencePostAllowed(instanceID string) bool { return true } +// postLicence godoc +// +// @Summary Set this instance's licence +// @Description Self-hosted only; a cloud instance's licence is injected by admin and this endpoint answers 409 cloud_managed. Exempt from the licence gate, since pasting a valid licence is the way out of degraded mode. Rate limited to 10 attempts per instance per hour. +// @Tags licence +// @Accept json +// @Produce json +// @Param body body object{blob=string} true "Licence key blob" +// @Success 200 {object} LicencePostResponse +// @Failure 400 {object} LicenceErrorResponse +// @Failure 409 {object} LicenceErrorResponse +// @Failure 429 {object} ErrorResponse +// @Security cookieAuth +// @Security bearerAuth +// @Router /license [post] func postLicence(c *gin.Context) { instanceID := auth.InstanceID(c) @@ -210,7 +234,7 @@ func postLicence(c *gin.Context) { services.LogEvent(instanceID, "license.updated", actorFromCtx(c), "", "", "licence accepted (tier "+st.Tier+")") - c.JSON(http.StatusOK, gin.H{"state": st.Status, "tier": st.Tier, "expires_at": st.ExpiresAt}) + c.JSON(http.StatusOK, LicencePostResponse{State: st.Status, Tier: st.Tier, ExpiresAt: st.ExpiresAt}) } // licenceRejectionMessage turns a machine reason into something a person can act @@ -238,11 +262,11 @@ func limitStatus(c *gin.Context, err error) bool { if !errors.As(err, &le) { return false } - c.JSON(http.StatusForbidden, gin.H{ - "error": "limit_exceeded", - "limit": le.Limit, - "current": le.Current, - "max": le.Max, + c.JSON(http.StatusForbidden, LimitExceededResponse{ + Error: "limit_exceeded", + Limit: le.Limit, + Current: le.Current, + Max: le.Max, }) return true }