feat: Annotate SSO, channel, console, instance and licence routes

Same treatment as the previous commit: named types replace gin.H literals,
and every handler gets a swaggo doc block.
This commit is contained in:
2026-08-12 15:22:48 +00:00
parent edb8406e05
commit a398da0eac
5 changed files with 277 additions and 21 deletions
+93 -8
View File
@@ -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"})
}
+60 -1
View File
@@ -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"})
}
+35 -4
View File
@@ -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()
+59 -2
View File
@@ -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 {
+30 -6
View File
@@ -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
}