diff --git a/backend/internal/controller/oidc_controller.go b/backend/internal/controller/oidc_controller.go index 86d26e14..46e75e69 100644 --- a/backend/internal/controller/oidc_controller.go +++ b/backend/internal/controller/oidc_controller.go @@ -63,6 +63,12 @@ func NewOidcController(group *gin.RouterGroup, authMiddleware *middleware.AuthMi group.GET("/oidc/users/me/clients", authMiddleware.WithAdminNotRequired().Add(), oc.listOwnAccessibleClientsHandler) + // OIDC API (Resource Server) routes + group.POST("/oidc/apis", authMiddleware.Add(), oc.createAPIHandler) + group.GET("/oidc/apis", authMiddleware.Add(), oc.listAPIsHandler) + group.GET("/oidc/apis/:id", authMiddleware.Add(), oc.getAPIHandler) + group.POST("/oidc/apis/:id", authMiddleware.Add(), oc.updateAPIHandler) + group.DELETE("/oidc/apis/:id", authMiddleware.Add(), oc.deleteAPIHandler) } type OidcController struct { @@ -845,3 +851,151 @@ func (oc *OidcController) getClientPreviewHandler(c *gin.Context) { c.JSON(http.StatusOK, preview) } + +// createAPIHandler godoc +// @Summary Create OIDC API +// @Description Create a new OIDC API (resource server) +// @Tags OIDC +// @Accept json +// @Produce json +// @Param api body dto.OidcAPICreateDto true "API information" +// @Success 201 {object} dto.OidcAPIDto "Created API" +// @Router /api/oidc/apis [post] +func (oc *OidcController) createAPIHandler(c *gin.Context) { + var input dto.OidcAPICreateDto + err := c.ShouldBindJSON(&input) + if err != nil { + _ = c.Error(err) + return + } + + api, err := oc.oidcService.CreateAPI(c.Request.Context(), input) + if err != nil { + _ = c.Error(err) + return + } + + var apiDto dto.OidcAPIDto + err = dto.MapStruct(api, &apiDto) + if err != nil { + _ = c.Error(err) + return + } + + c.JSON(http.StatusCreated, apiDto) +} + +// listAPIsHandler godoc +// @Summary List OIDC APIs +// @Description Get a paginated list of OIDC APIs (resource servers) +// @Tags OIDC +// @Param search query string false "Search term to filter APIs by name" +// @Param pagination[page] query int false "Page number for pagination" default(1) +// @Param pagination[limit] query int false "Number of items per page" default(20) +// @Param sort[column] query string false "Column to sort by" +// @Param sort[direction] query string false "Sort direction (asc or desc)" default("asc") +// @Success 200 {object} dto.Paginated[dto.OidcAPIDto] +// @Router /api/oidc/apis [get] +func (oc *OidcController) listAPIsHandler(c *gin.Context) { + searchTerm := c.Query("search") + listRequestOptions := utils.ParseListRequestOptions(c) + + apis, pagination, err := oc.oidcService.ListAPIs(c.Request.Context(), searchTerm, listRequestOptions) + if err != nil { + _ = c.Error(err) + return + } + + apisDto := make([]dto.OidcAPIDto, len(apis)) + for i, api := range apis { + var apiDto dto.OidcAPIDto + err = dto.MapStruct(api, &apiDto) + if err != nil { + _ = c.Error(err) + return + } + apisDto[i] = apiDto + } + + c.JSON(http.StatusOK, dto.Paginated[dto.OidcAPIDto]{ + Data: apisDto, + Pagination: pagination, + }) +} + +// getAPIHandler godoc +// @Summary Get OIDC API +// @Description Get detailed information about an OIDC API (resource server) +// @Tags OIDC +// @Produce json +// @Param id path string true "API ID" +// @Success 200 {object} dto.OidcAPIDto "API information" +// @Router /api/oidc/apis/{id} [get] +func (oc *OidcController) getAPIHandler(c *gin.Context) { + apiID := c.Param("id") + api, err := oc.oidcService.GetAPI(c.Request.Context(), apiID) + if err != nil { + _ = c.Error(err) + return + } + + var apiDto dto.OidcAPIDto + err = dto.MapStruct(api, &apiDto) + if err != nil { + _ = c.Error(err) + return + } + + c.JSON(http.StatusOK, apiDto) +} + +// updateAPIHandler godoc +// @Summary Update OIDC API +// @Description Update an existing OIDC API (resource server) +// @Tags OIDC +// @Accept json +// @Produce json +// @Param id path string true "API ID" +// @Param api body dto.OidcAPIUpdateDto true "API information" +// @Success 200 {object} dto.OidcAPIDto "Updated API" +// @Router /api/oidc/apis/{id} [post] +func (oc *OidcController) updateAPIHandler(c *gin.Context) { + var input dto.OidcAPIUpdateDto + err := c.ShouldBindJSON(&input) + if err != nil { + _ = c.Error(err) + return + } + + api, err := oc.oidcService.UpdateAPI(c.Request.Context(), c.Param("id"), input) + if err != nil { + _ = c.Error(err) + return + } + + var apiDto dto.OidcAPIDto + err = dto.MapStruct(api, &apiDto) + if err != nil { + _ = c.Error(err) + return + } + + c.JSON(http.StatusOK, apiDto) +} + +// deleteAPIHandler godoc +// @Summary Delete OIDC API +// @Description Delete an OIDC API (resource server) by ID +// @Tags OIDC +// @Param id path string true "API ID" +// @Success 204 "No Content" +// @Router /api/oidc/apis/{id} [delete] +func (oc *OidcController) deleteAPIHandler(c *gin.Context) { + err := oc.oidcService.DeleteAPI(c.Request.Context(), c.Param("id")) + if err != nil { + _ = c.Error(err) + return + } + + c.Status(http.StatusNoContent) +} diff --git a/backend/internal/dto/oidc_dto.go b/backend/internal/dto/oidc_dto.go index 9f3239de..481d96cb 100644 --- a/backend/internal/dto/oidc_dto.go +++ b/backend/internal/dto/oidc_dto.go @@ -178,3 +178,26 @@ type AccessibleOidcClientDto struct { OidcClientMetaDataDto LastUsedAt *datatype.DateTime `json:"lastUsedAt"` } + +type OidcAPIDto struct { + ID string `json:"id"` + Name string `json:"name"` + Identifier string `json:"identifier"` + Permissions []OidcAPIPermissionDto `json:"permissions"` + CreatedAt datatype.DateTime `json:"createdAt"` +} + +type OidcAPIPermissionDto struct { + Name string `json:"name"` + Description string `json:"description"` +} + +type OidcAPICreateDto struct { + Name string `json:"name" binding:"required,max=100" unorm:"nfc"` +} + +type OidcAPIUpdateDto struct { + Name string `json:"name" binding:"required,max=100" unorm:"nfc"` + Identifier string `json:"identifier" binding:"omitempty,url,max=255"` + Permissions []OidcAPIPermissionDto `json:"permissions" binding:"omitempty,dive"` +} diff --git a/backend/internal/model/oidc.go b/backend/internal/model/oidc.go index 1aaebe91..68ee2664 100644 --- a/backend/internal/model/oidc.go +++ b/backend/internal/model/oidc.go @@ -151,3 +151,33 @@ type OidcDeviceCode struct { ClientID string Client OidcClient } + +type OidcAPI struct { + Base + + Name string `sortable:"true"` + Identifier string `sortable:"true"` + Data OidcAPIData `gorm:"type:text"` +} + +func (a OidcAPI) DefaultIdentifier() string { + return "api://" + a.Identifier +} + +//nolint:recvcheck +type OidcAPIData struct { + Permissions []OidcAPIPermission `json:"permissions"` +} + +func (p *OidcAPIData) Scan(value any) error { + return utils.UnmarshalJSONFromDatabase(p, value) +} + +func (p OidcAPIData) Value() (driver.Value, error) { + return json.Marshal(p) +} + +type OidcAPIPermission struct { + Name string `json:"name"` + Description string `json:"description"` +} diff --git a/backend/internal/service/oidc_service.go b/backend/internal/service/oidc_service.go index 2d42f452..fff76dca 100644 --- a/backend/internal/service/oidc_service.go +++ b/backend/internal/service/oidc_service.go @@ -1433,7 +1433,6 @@ func (s *OidcService) GetAllowedGroupsCountOfClient(ctx context.Context, id stri } func (s *OidcService) ListAuthorizedClients(ctx context.Context, userID string, listRequestOptions utils.ListRequestOptions) ([]model.UserAuthorizedOidcClient, utils.PaginationResponse, error) { - query := s.db. WithContext(ctx). Model(&model.UserAuthorizedOidcClient{}). @@ -2123,3 +2122,106 @@ func (s *OidcService) updateClientLogoType(ctx context.Context, clientID string, return nil } + +func (s *OidcService) CreateAPI(ctx context.Context, input dto.OidcAPICreateDto) (model.OidcAPI, error) { + api := model.OidcAPI{ + Name: input.Name, + } + + err := s.db. + WithContext(ctx). + Create(&api). + Error + if err != nil { + return model.OidcAPI{}, fmt.Errorf("failed to create API in database: %w", err) + } + + return api, nil +} + +func (s *OidcService) GetAPI(ctx context.Context, id string) (model.OidcAPI, error) { + var api model.OidcAPI + err := s.db. + WithContext(ctx). + First(&api, "id = ?", id). + Error + if err != nil { + return model.OidcAPI{}, fmt.Errorf("failed to get API from database: %w", err) + } + + return api, nil +} + +func (s *OidcService) ListAPIs(ctx context.Context, searchTerm string, listRequestOptions utils.ListRequestOptions) ([]model.OidcAPI, utils.PaginationResponse, error) { + var apis []model.OidcAPI + + query := s.db. + WithContext(ctx). + Model(&model.OidcAPI{}) + + if searchTerm != "" { + query = query.Where("id = ? OR name = ? OR identifier = ?", searchTerm, searchTerm, searchTerm) + } + + response, err := utils.PaginateFilterAndSort(listRequestOptions, query, &apis) + return apis, response, err +} + +func (s *OidcService) UpdateAPI(ctx context.Context, id string, input dto.OidcAPIUpdateDto) (model.OidcAPI, error) { + tx := s.db.Begin() + defer func() { + tx.Rollback() + }() + + var api model.OidcAPI + err := tx. + WithContext(ctx). + First(&api, "id = ?", id). + Error + if err != nil { + return model.OidcAPI{}, fmt.Errorf("failed to get API from database: %w", err) + } + + api.Name = input.Name + api.Identifier = input.Identifier + + // Convert permissions from DTO to model + api.Data.Permissions = make([]model.OidcAPIPermission, len(input.Permissions)) + for i, p := range input.Permissions { + api.Data.Permissions[i] = model.OidcAPIPermission{ + Name: p.Name, + Description: p.Description, + } + } + + err = tx. + WithContext(ctx). + Save(&api). + Error + if err != nil { + return model.OidcAPI{}, fmt.Errorf("failed to save API in database: %w", err) + } + + err = tx.Commit().Error + if err != nil { + return model.OidcAPI{}, fmt.Errorf("failed to commit transaction: %w", err) + } + + return api, nil +} + +func (s *OidcService) DeleteAPI(ctx context.Context, id string) error { + result := s.db. + WithContext(ctx). + Delete(&model.OidcAPI{}, "id = ?", id) + + if result.Error != nil { + return result.Error + } + + if result.RowsAffected == 0 { + return gorm.ErrRecordNotFound + } + + return nil +} diff --git a/backend/resources/migrations/postgres/20251129000000_oidc_api.drown.sql b/backend/resources/migrations/postgres/20251129000000_oidc_api.drown.sql new file mode 100644 index 00000000..b5410612 --- /dev/null +++ b/backend/resources/migrations/postgres/20251129000000_oidc_api.drown.sql @@ -0,0 +1 @@ +DROP TABLE oidc_apis; diff --git a/backend/resources/migrations/postgres/20251129000000_oidc_api.up.sql b/backend/resources/migrations/postgres/20251129000000_oidc_api.up.sql new file mode 100644 index 00000000..e411325b --- /dev/null +++ b/backend/resources/migrations/postgres/20251129000000_oidc_api.up.sql @@ -0,0 +1,11 @@ +CREATE TABLE oidc_apis +( + id UUID PRIMARY KEY NOT NULL, + created_at TIMESTAMPTZ NOT NULL, + name TEXT NOT NULL, + identifier TEXT NOT NULL, + data JSONB NOT NULL +); + +CREATE UNIQUE INDEX idx_oidc_apis_identifier_key ON oidc_apis(identifier); +CREATE INDEX idx_oidc_apis_name_key ON oidc_apis(name); diff --git a/backend/resources/migrations/sqlite/20251129000000_oidc_api.drown.sql b/backend/resources/migrations/sqlite/20251129000000_oidc_api.drown.sql new file mode 100644 index 00000000..b5410612 --- /dev/null +++ b/backend/resources/migrations/sqlite/20251129000000_oidc_api.drown.sql @@ -0,0 +1 @@ +DROP TABLE oidc_apis; diff --git a/backend/resources/migrations/sqlite/20251129000000_oidc_api.up.sql b/backend/resources/migrations/sqlite/20251129000000_oidc_api.up.sql new file mode 100644 index 00000000..4b65b0fc --- /dev/null +++ b/backend/resources/migrations/sqlite/20251129000000_oidc_api.up.sql @@ -0,0 +1,11 @@ +CREATE TABLE oidc_apis +( + id UUID PRIMARY KEY NOT NULL, + created_at DATETIME NOT NULL, + name TEXT NOT NULL, + identifier TEXT NOT NULL, + data TEXT NOT NULL +); + +CREATE UNIQUE INDEX idx_oidc_apis_identifier_key ON oidc_apis(identifier); +CREATE INDEX idx_oidc_apis_name_key ON oidc_apis(name);