From 9b00a3302431692c38ecc648663ef4a61c1c3449 Mon Sep 17 00:00:00 2001 From: Anar Sultanov Date: Mon, 26 Feb 2024 19:27:37 +0100 Subject: [PATCH] fix: Correct response codes and improve OpenAPI schema --- docs/openapi.json | 449 +++++++++++++++++- docs/openapi.yaml | 313 +++++++++++- pom.xml | 2 + .../resource/TenantInvitationsResource.java | 22 +- .../resource/TenantMembershipsResource.java | 21 +- .../multitenancy/resource/TenantResource.java | 22 + .../resource/TenantsResource.java | 19 +- 7 files changed, 819 insertions(+), 29 deletions(-) diff --git a/docs/openapi.json b/docs/openapi.json index 42d59f5..598598b 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -49,6 +49,9 @@ } } } + }, + "401" : { + "description" : "Unauthorized" } } }, @@ -68,6 +71,12 @@ "responses" : { "201" : { "description" : "Created" + }, + "401" : { + "description" : "Unauthorized" + }, + "403" : { + "description" : "Forbidden" } } } @@ -82,10 +91,33 @@ "content" : { "application/json" : { "schema" : { - "$ref" : "#/components/schemas/TenantRepresentation" + "required" : [ "name" ], + "type" : "object", + "properties" : { + "id" : { + "type" : "string", + "readOnly" : true + }, + "name" : { + "type" : "string" + }, + "realm" : { + "type" : "string", + "readOnly" : true + } + } } } } + }, + "401" : { + "description" : "Unauthorized" + }, + "403" : { + "description" : "Forbidden" + }, + "404" : { + "description" : "Not Found" } } }, @@ -102,8 +134,17 @@ } }, "responses" : { - "200" : { - "description" : "OK" + "204" : { + "description" : "No Content" + }, + "401" : { + "description" : "Unauthorized" + }, + "403" : { + "description" : "Forbidden" + }, + "404" : { + "description" : "Not Found" } } }, @@ -113,6 +154,12 @@ "responses" : { "204" : { "description" : "No Content" + }, + "401" : { + "description" : "Unauthorized" + }, + "403" : { + "description" : "Forbidden" } } }, @@ -166,6 +213,12 @@ } } } + }, + "401" : { + "description" : "Unauthorized" + }, + "403" : { + "description" : "Forbidden" } } }, @@ -185,6 +238,12 @@ "responses" : { "201" : { "description" : "Created" + }, + "401" : { + "description" : "Unauthorized" + }, + "403" : { + "description" : "Forbidden" } } }, @@ -210,8 +269,17 @@ } } ], "responses" : { - "200" : { - "description" : "OK" + "204" : { + "description" : "No Content" + }, + "401" : { + "description" : "Unauthorized" + }, + "403" : { + "description" : "Forbidden" + }, + "404" : { + "description" : "Not Found" } } }, @@ -265,6 +333,12 @@ } } } + }, + "401" : { + "description" : "Unauthorized" + }, + "403" : { + "description" : "Forbidden" } } }, @@ -290,8 +364,17 @@ } } ], "responses" : { - "200" : { - "description" : "OK" + "204" : { + "description" : "No Content" + }, + "401" : { + "description" : "Unauthorized" + }, + "403" : { + "description" : "Forbidden" + }, + "404" : { + "description" : "Not Found" } } }, @@ -317,8 +400,17 @@ "required" : true }, "responses" : { - "200" : { - "description" : "OK" + "204" : { + "description" : "No Content" + }, + "401" : { + "description" : "Unauthorized" + }, + "403" : { + "description" : "Forbidden" + }, + "404" : { + "description" : "Not Found" } } }, @@ -334,6 +426,111 @@ }, "components" : { "schemas" : { + "CredentialRepresentation" : { + "type" : "object", + "properties" : { + "id" : { + "type" : "string" + }, + "type" : { + "type" : "string" + }, + "userLabel" : { + "type" : "string" + }, + "createdDate" : { + "format" : "int64", + "type" : "integer" + }, + "secretData" : { + "type" : "string" + }, + "credentialData" : { + "type" : "string" + }, + "priority" : { + "format" : "int32", + "type" : "integer" + }, + "value" : { + "type" : "string" + }, + "temporary" : { + "type" : "boolean" + }, + "device" : { + "type" : "string", + "deprecated" : true + }, + "hashedSaltedValue" : { + "type" : "string", + "deprecated" : true + }, + "salt" : { + "type" : "string", + "deprecated" : true + }, + "hashIterations" : { + "format" : "int32", + "type" : "integer", + "deprecated" : true + }, + "counter" : { + "format" : "int32", + "type" : "integer", + "deprecated" : true + }, + "algorithm" : { + "type" : "string", + "deprecated" : true + }, + "digits" : { + "format" : "int32", + "type" : "integer", + "deprecated" : true + }, + "period" : { + "format" : "int32", + "type" : "integer", + "deprecated" : true + }, + "config" : { + "type" : "object", + "additionalProperties" : { + "type" : "string" + }, + "deprecated" : true + } + } + }, + "FederatedIdentityRepresentation" : { + "type" : "object", + "properties" : { + "identityProvider" : { + "type" : "string" + }, + "userId" : { + "type" : "string" + }, + "userName" : { + "type" : "string" + } + } + }, + "SocialLinkRepresentation" : { + "type" : "object", + "properties" : { + "socialProvider" : { + "type" : "string" + }, + "socialUserId" : { + "type" : "string" + }, + "socialUsername" : { + "type" : "string" + } + } + }, "TenantInvitationRepresentation" : { "required" : [ "email", "roles" ], "type" : "object", @@ -375,6 +572,9 @@ }, "user" : { "type" : "object", + "allOf" : [ { + "$ref" : "#/components/schemas/UserRepresentation" + } ], "readOnly" : true }, "roles" : { @@ -402,6 +602,237 @@ "readOnly" : true } } + }, + "UserConsentRepresentation" : { + "type" : "object", + "properties" : { + "clientId" : { + "type" : "string" + }, + "grantedClientScopes" : { + "type" : "array", + "items" : { + "type" : "string" + } + }, + "createdDate" : { + "format" : "int64", + "type" : "integer" + }, + "lastUpdatedDate" : { + "format" : "int64", + "type" : "integer" + }, + "grantedRealmRoles" : { + "type" : "array", + "items" : { + "type" : "string" + }, + "deprecated" : true + } + } + }, + "UserProfileAttributeGroupMetadata" : { + "type" : "object", + "properties" : { + "name" : { + "type" : "string" + }, + "displayHeader" : { + "type" : "string" + }, + "displayDescription" : { + "type" : "string" + }, + "annotations" : { + "type" : "object", + "additionalProperties" : { } + } + } + }, + "UserProfileAttributeMetadata" : { + "type" : "object", + "properties" : { + "name" : { + "type" : "string" + }, + "displayName" : { + "type" : "string" + }, + "required" : { + "type" : "boolean" + }, + "readOnly" : { + "type" : "boolean" + }, + "annotations" : { + "type" : "object", + "additionalProperties" : { } + }, + "validators" : { + "type" : "object", + "additionalProperties" : { + "type" : "object", + "additionalProperties" : { } + } + }, + "group" : { + "type" : "string" + } + } + }, + "UserProfileMetadata" : { + "type" : "object", + "properties" : { + "attributes" : { + "type" : "array", + "items" : { + "$ref" : "#/components/schemas/UserProfileAttributeMetadata" + } + }, + "groups" : { + "type" : "array", + "items" : { + "$ref" : "#/components/schemas/UserProfileAttributeGroupMetadata" + } + } + } + }, + "UserRepresentation" : { + "type" : "object", + "properties" : { + "self" : { + "type" : "string" + }, + "id" : { + "type" : "string" + }, + "origin" : { + "type" : "string" + }, + "createdTimestamp" : { + "format" : "int64", + "type" : "integer" + }, + "username" : { + "type" : "string" + }, + "enabled" : { + "type" : "boolean" + }, + "totp" : { + "type" : "boolean" + }, + "emailVerified" : { + "type" : "boolean" + }, + "firstName" : { + "type" : "string" + }, + "lastName" : { + "type" : "string" + }, + "email" : { + "type" : "string" + }, + "federationLink" : { + "type" : "string" + }, + "serviceAccountClientId" : { + "type" : "string" + }, + "attributes" : { + "type" : "object", + "additionalProperties" : { + "type" : "array", + "items" : { + "type" : "string" + } + } + }, + "credentials" : { + "type" : "array", + "items" : { + "$ref" : "#/components/schemas/CredentialRepresentation" + } + }, + "disableableCredentialTypes" : { + "uniqueItems" : true, + "type" : "array", + "items" : { + "type" : "string" + } + }, + "requiredActions" : { + "type" : "array", + "items" : { + "type" : "string" + } + }, + "federatedIdentities" : { + "type" : "array", + "items" : { + "$ref" : "#/components/schemas/FederatedIdentityRepresentation" + } + }, + "realmRoles" : { + "type" : "array", + "items" : { + "type" : "string" + } + }, + "clientRoles" : { + "type" : "object", + "additionalProperties" : { + "type" : "array", + "items" : { + "type" : "string" + } + } + }, + "clientConsents" : { + "type" : "array", + "items" : { + "$ref" : "#/components/schemas/UserConsentRepresentation" + } + }, + "notBefore" : { + "format" : "int32", + "type" : "integer" + }, + "applicationRoles" : { + "type" : "object", + "additionalProperties" : { + "type" : "array", + "items" : { + "type" : "string" + } + }, + "deprecated" : true + }, + "socialLinks" : { + "type" : "array", + "items" : { + "$ref" : "#/components/schemas/SocialLinkRepresentation" + }, + "deprecated" : true + }, + "groups" : { + "type" : "array", + "items" : { + "type" : "string" + } + }, + "access" : { + "type" : "object", + "additionalProperties" : { + "type" : "boolean" + } + }, + "userProfileMetadata" : { + "$ref" : "#/components/schemas/UserProfileMetadata" + } + } } } } diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 7651b70..5c1f926 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -37,6 +37,8 @@ paths: type: array items: $ref: '#/components/schemas/TenantRepresentation' + "401": + description: Unauthorized post: summary: Create a tenant operationId: createTenant @@ -49,6 +51,10 @@ paths: responses: "201": description: Created + "401": + description: Unauthorized + "403": + description: Forbidden /tenants/{tenantId}: get: summary: Get tenant @@ -59,7 +65,24 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/TenantRepresentation' + required: + - name + type: object + properties: + id: + type: string + readOnly: true + name: + type: string + realm: + type: string + readOnly: true + "401": + description: Unauthorized + "403": + description: Forbidden + "404": + description: Not Found put: summary: Update tenant operationId: updateTenant @@ -69,14 +92,24 @@ paths: schema: $ref: '#/components/schemas/TenantRepresentation' responses: - "200": - description: OK + "204": + description: No Content + "401": + description: Unauthorized + "403": + description: Forbidden + "404": + description: Not Found delete: summary: Delete tenant operationId: deleteTenant responses: "204": description: No Content + "401": + description: Unauthorized + "403": + description: Forbidden parameters: - name: tenantId in: path @@ -114,6 +147,10 @@ paths: type: array items: $ref: '#/components/schemas/TenantInvitationRepresentation' + "401": + description: Unauthorized + "403": + description: Forbidden post: summary: Create invitation operationId: createInvitation @@ -126,6 +163,10 @@ paths: responses: "201": description: Created + "401": + description: Unauthorized + "403": + description: Forbidden parameters: - name: tenantId in: path @@ -143,8 +184,14 @@ paths: schema: type: string responses: - "200": - description: OK + "204": + description: No Content + "401": + description: Unauthorized + "403": + description: Forbidden + "404": + description: Not Found parameters: - name: tenantId in: path @@ -182,6 +229,10 @@ paths: type: array items: $ref: '#/components/schemas/TenantMembershipRepresentation' + "401": + description: Unauthorized + "403": + description: Forbidden parameters: - name: tenantId in: path @@ -199,8 +250,14 @@ paths: schema: type: string responses: - "200": - description: OK + "204": + description: No Content + "401": + description: Unauthorized + "403": + description: Forbidden + "404": + description: Not Found patch: summary: Update tenant membership operationId: updateMembership @@ -217,8 +274,14 @@ paths: $ref: '#/components/schemas/TenantMembershipRepresentation' required: true responses: - "200": - description: OK + "204": + description: No Content + "401": + description: Unauthorized + "403": + description: Forbidden + "404": + description: Not Found parameters: - name: tenantId in: path @@ -227,6 +290,80 @@ paths: type: string components: schemas: + CredentialRepresentation: + type: object + properties: + id: + type: string + type: + type: string + userLabel: + type: string + createdDate: + format: int64 + type: integer + secretData: + type: string + credentialData: + type: string + priority: + format: int32 + type: integer + value: + type: string + temporary: + type: boolean + device: + type: string + deprecated: true + hashedSaltedValue: + type: string + deprecated: true + salt: + type: string + deprecated: true + hashIterations: + format: int32 + type: integer + deprecated: true + counter: + format: int32 + type: integer + deprecated: true + algorithm: + type: string + deprecated: true + digits: + format: int32 + type: integer + deprecated: true + period: + format: int32 + type: integer + deprecated: true + config: + type: object + additionalProperties: + type: string + deprecated: true + FederatedIdentityRepresentation: + type: object + properties: + identityProvider: + type: string + userId: + type: string + userName: + type: string + SocialLinkRepresentation: + type: object + properties: + socialProvider: + type: string + socialUserId: + type: string + socialUsername: + type: string TenantInvitationRepresentation: required: - email @@ -261,6 +398,8 @@ components: readOnly: true user: type: object + allOf: + - $ref: '#/components/schemas/UserRepresentation' readOnly: true roles: uniqueItems: true @@ -280,3 +419,159 @@ components: realm: type: string readOnly: true + UserConsentRepresentation: + type: object + properties: + clientId: + type: string + grantedClientScopes: + type: array + items: + type: string + createdDate: + format: int64 + type: integer + lastUpdatedDate: + format: int64 + type: integer + grantedRealmRoles: + type: array + items: + type: string + deprecated: true + UserProfileAttributeGroupMetadata: + type: object + properties: + name: + type: string + displayHeader: + type: string + displayDescription: + type: string + annotations: + type: object + additionalProperties: {} + UserProfileAttributeMetadata: + type: object + properties: + name: + type: string + displayName: + type: string + required: + type: boolean + readOnly: + type: boolean + annotations: + type: object + additionalProperties: {} + validators: + type: object + additionalProperties: + type: object + additionalProperties: {} + group: + type: string + UserProfileMetadata: + type: object + properties: + attributes: + type: array + items: + $ref: '#/components/schemas/UserProfileAttributeMetadata' + groups: + type: array + items: + $ref: '#/components/schemas/UserProfileAttributeGroupMetadata' + UserRepresentation: + type: object + properties: + self: + type: string + id: + type: string + origin: + type: string + createdTimestamp: + format: int64 + type: integer + username: + type: string + enabled: + type: boolean + totp: + type: boolean + emailVerified: + type: boolean + firstName: + type: string + lastName: + type: string + email: + type: string + federationLink: + type: string + serviceAccountClientId: + type: string + attributes: + type: object + additionalProperties: + type: array + items: + type: string + credentials: + type: array + items: + $ref: '#/components/schemas/CredentialRepresentation' + disableableCredentialTypes: + uniqueItems: true + type: array + items: + type: string + requiredActions: + type: array + items: + type: string + federatedIdentities: + type: array + items: + $ref: '#/components/schemas/FederatedIdentityRepresentation' + realmRoles: + type: array + items: + type: string + clientRoles: + type: object + additionalProperties: + type: array + items: + type: string + clientConsents: + type: array + items: + $ref: '#/components/schemas/UserConsentRepresentation' + notBefore: + format: int32 + type: integer + applicationRoles: + type: object + additionalProperties: + type: array + items: + type: string + deprecated: true + socialLinks: + type: array + items: + $ref: '#/components/schemas/SocialLinkRepresentation' + deprecated: true + groups: + type: array + items: + type: string + access: + type: object + additionalProperties: + type: boolean + userProfileMetadata: + $ref: '#/components/schemas/UserProfileMetadata' diff --git a/pom.xml b/pom.xml index 2dc961e..57e6c34 100644 --- a/pom.xml +++ b/pom.xml @@ -181,6 +181,8 @@ ${project.name} ${project.version} {{keycloakUrl}}/auth/realms/{{realmName}} + provided + dev.sultanov.keycloak.multitenancy.resource,org.keycloak.representations.idm /tenants diff --git a/src/main/java/dev/sultanov/keycloak/multitenancy/resource/TenantInvitationsResource.java b/src/main/java/dev/sultanov/keycloak/multitenancy/resource/TenantInvitationsResource.java index a1fa4cb..7a9d390 100644 --- a/src/main/java/dev/sultanov/keycloak/multitenancy/resource/TenantInvitationsResource.java +++ b/src/main/java/dev/sultanov/keycloak/multitenancy/resource/TenantInvitationsResource.java @@ -33,9 +33,9 @@ import org.eclipse.microprofile.openapi.annotations.parameters.Parameter; import org.eclipse.microprofile.openapi.annotations.parameters.RequestBody; import org.eclipse.microprofile.openapi.annotations.responses.APIResponse; +import org.eclipse.microprofile.openapi.annotations.responses.APIResponses; import org.keycloak.events.admin.OperationType; import org.keycloak.models.Constants; -import org.keycloak.models.KeycloakSession; import org.keycloak.models.UserModel; import org.keycloak.models.utils.KeycloakModelUtils; @@ -52,7 +52,11 @@ public TenantInvitationsResource(AbstractAdminResource parent, @Consumes(MediaType.APPLICATION_JSON) @Produces(MediaType.APPLICATION_JSON) @Operation(operationId = "createInvitation", summary = "Create invitation") - @APIResponse(responseCode = "201", description = "Created") + @APIResponses({ + @APIResponse(responseCode = "201", description = "Created"), + @APIResponse(responseCode = "401", description = "Unauthorized"), + @APIResponse(responseCode = "403", description = "Forbidden") + }) public Response createInvitation(@RequestBody(required = true) TenantInvitationRepresentation request) { String email = request.getEmail(); if (!isValidEmail(email)) { @@ -93,7 +97,11 @@ public Response createInvitation(@RequestBody(required = true) TenantInvitationR @GET @Produces(MediaType.APPLICATION_JSON) @Operation(operationId = "listInvitations", summary = "List invitations") - @APIResponse(responseCode = "200", description = "OK", content = @Content(schema = @Schema(type = SchemaType.ARRAY, implementation = TenantInvitationRepresentation.class))) + @APIResponses({ + @APIResponse(responseCode = "200", description = "OK", content = @Content(schema = @Schema(type = SchemaType.ARRAY, implementation = TenantInvitationRepresentation.class))), + @APIResponse(responseCode = "401", description = "Unauthorized"), + @APIResponse(responseCode = "403", description = "Forbidden") + }) public Stream listInvitations( @Parameter(description = "Invitee email") @QueryParam("search") String searchQuery, @Parameter(description = "Pagination offset") @QueryParam("first") Integer firstResult, @@ -112,13 +120,19 @@ public Stream listInvitations( @DELETE @Operation(operationId = "revokeInvitation", summary = "Revoke invitation") @Path("{invitationId}") + @APIResponses({ + @APIResponse(responseCode = "204", description = "No Content"), + @APIResponse(responseCode = "401", description = "Unauthorized"), + @APIResponse(responseCode = "403", description = "Forbidden"), + @APIResponse(responseCode = "404", description = "Not Found") + }) public Response revokeInvitation(@PathParam("invitationId") String invitationId) { var revoked = tenant.revokeInvitation(invitationId); if (revoked) { adminEvent.operation(OperationType.DELETE) .resourcePath(session.getContext().getUri()) .success(); - return Response.status(204).build(); + return Response.noContent().build(); } else { throw new NotFoundException(String.format("No invitation with id %s", invitationId)); } diff --git a/src/main/java/dev/sultanov/keycloak/multitenancy/resource/TenantMembershipsResource.java b/src/main/java/dev/sultanov/keycloak/multitenancy/resource/TenantMembershipsResource.java index cf6043b..4b9f34a 100644 --- a/src/main/java/dev/sultanov/keycloak/multitenancy/resource/TenantMembershipsResource.java +++ b/src/main/java/dev/sultanov/keycloak/multitenancy/resource/TenantMembershipsResource.java @@ -23,6 +23,7 @@ import org.eclipse.microprofile.openapi.annotations.parameters.Parameter; import org.eclipse.microprofile.openapi.annotations.parameters.RequestBody; import org.eclipse.microprofile.openapi.annotations.responses.APIResponse; +import org.eclipse.microprofile.openapi.annotations.responses.APIResponses; import org.keycloak.events.admin.OperationType; import org.keycloak.models.Constants; import org.keycloak.utils.StringUtil; @@ -39,7 +40,11 @@ public TenantMembershipsResource(AbstractAdminResource parent, @GET @Produces(MediaType.APPLICATION_JSON) @Operation(operationId = "listMemberships", summary = "List tenant memberships") - @APIResponse(responseCode = "200", description = "OK", content = @Content(schema = @Schema(type = SchemaType.ARRAY, implementation = TenantMembershipRepresentation.class))) + @APIResponses({ + @APIResponse(responseCode = "200", description = "OK", content = @Content(schema = @Schema(type = SchemaType.ARRAY, implementation = TenantMembershipRepresentation.class))), + @APIResponse(responseCode = "401", description = "Unauthorized"), + @APIResponse(responseCode = "403", description = "Forbidden") + }) public Stream listMemberships( @Parameter(description = "Member email") @QueryParam("search") String search, @Parameter(description = "Pagination offset") @QueryParam("first") Integer firstResult, @@ -63,6 +68,12 @@ public Stream listMemberships( @Produces(MediaType.APPLICATION_JSON) @Consumes(value = MediaType.APPLICATION_JSON) @Operation(operationId = "updateMembership", summary = "Update tenant membership") + @APIResponses({ + @APIResponse(responseCode = "204", description = "No Content"), + @APIResponse(responseCode = "401", description = "Unauthorized"), + @APIResponse(responseCode = "403", description = "Forbidden"), + @APIResponse(responseCode = "404", description = "Not Found") + }) public Response update(@PathParam("membershipId") String membershipId, @RequestBody(required = true) TenantMembershipRepresentation request) { var optionalMembership = tenant.getMembershipById(membershipId); if (optionalMembership.isEmpty()) { @@ -81,13 +92,19 @@ public Response update(@PathParam("membershipId") String membershipId, @RequestB @DELETE @Path("{membershipId}") @Operation(operationId = "revokeMembership", summary = "Revoke tenant membership") + @APIResponses({ + @APIResponse(responseCode = "204", description = "No Content"), + @APIResponse(responseCode = "401", description = "Unauthorized"), + @APIResponse(responseCode = "403", description = "Forbidden"), + @APIResponse(responseCode = "404", description = "Not Found") + }) public Response revokeMembership(@PathParam("membershipId") String membershipId) { var revoked = tenant.revokeMembership(membershipId); if (revoked) { adminEvent.operation(OperationType.DELETE) .resourcePath(session.getContext().getUri()) .success(); - return Response.status(204).build(); + return Response.noContent().build(); } else { throw new NotFoundException(String.format("No membership with id %s", membershipId)); } diff --git a/src/main/java/dev/sultanov/keycloak/multitenancy/resource/TenantResource.java b/src/main/java/dev/sultanov/keycloak/multitenancy/resource/TenantResource.java index 0a0b0a4..ea155c0 100644 --- a/src/main/java/dev/sultanov/keycloak/multitenancy/resource/TenantResource.java +++ b/src/main/java/dev/sultanov/keycloak/multitenancy/resource/TenantResource.java @@ -11,6 +11,11 @@ import jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.Response; import org.eclipse.microprofile.openapi.annotations.Operation; +import org.eclipse.microprofile.openapi.annotations.enums.SchemaType; +import org.eclipse.microprofile.openapi.annotations.media.Content; +import org.eclipse.microprofile.openapi.annotations.media.Schema; +import org.eclipse.microprofile.openapi.annotations.responses.APIResponse; +import org.eclipse.microprofile.openapi.annotations.responses.APIResponses; import org.keycloak.events.admin.OperationType; public class TenantResource extends AbstractAdminResource { @@ -25,6 +30,12 @@ public TenantResource(AbstractAdminResource parent, TenantModel @GET @Produces(MediaType.APPLICATION_JSON) @Operation(operationId = "getTenant", summary = "Get tenant") + @APIResponses({ + @APIResponse(responseCode = "200", description = "OK", content = @Content(schema = @Schema(type = SchemaType.OBJECT, implementation = TenantRepresentation.class))), + @APIResponse(responseCode = "401", description = "Unauthorized"), + @APIResponse(responseCode = "403", description = "Forbidden"), + @APIResponse(responseCode = "404", description = "Not Found") + }) public TenantRepresentation getTenant() { return ModelMapper.toRepresentation(tenant); } @@ -33,6 +44,12 @@ public TenantRepresentation getTenant() { @Produces(MediaType.APPLICATION_JSON) @Consumes(value = MediaType.APPLICATION_JSON) @Operation(operationId = "updateTenant", summary = "Update tenant") + @APIResponses({ + @APIResponse(responseCode = "204", description = "No Content"), + @APIResponse(responseCode = "401", description = "Unauthorized"), + @APIResponse(responseCode = "403", description = "Forbidden"), + @APIResponse(responseCode = "404", description = "Not Found") + }) public Response updateTenant(TenantRepresentation request) { tenant.setName(request.getName()); @@ -47,6 +64,11 @@ public Response updateTenant(TenantRepresentation request) { @DELETE @Operation(operationId = "deleteTenant", summary = "Delete tenant") + @APIResponses({ + @APIResponse(responseCode = "204", description = "No Content"), + @APIResponse(responseCode = "401", description = "Unauthorized"), + @APIResponse(responseCode = "403", description = "Forbidden") + }) public void deleteTenant() { if (tenantProvider.deleteTenant(realm, tenant.getId())) { adminEvent.operation(OperationType.DELETE) diff --git a/src/main/java/dev/sultanov/keycloak/multitenancy/resource/TenantsResource.java b/src/main/java/dev/sultanov/keycloak/multitenancy/resource/TenantsResource.java index b0a7c33..ffb2e41 100644 --- a/src/main/java/dev/sultanov/keycloak/multitenancy/resource/TenantsResource.java +++ b/src/main/java/dev/sultanov/keycloak/multitenancy/resource/TenantsResource.java @@ -3,8 +3,8 @@ import dev.sultanov.keycloak.multitenancy.model.TenantModel; import dev.sultanov.keycloak.multitenancy.resource.representation.TenantRepresentation; import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.ForbiddenException; import jakarta.ws.rs.GET; -import jakarta.ws.rs.NotAuthorizedException; import jakarta.ws.rs.NotFoundException; import jakarta.ws.rs.POST; import jakarta.ws.rs.Path; @@ -22,10 +22,12 @@ import org.eclipse.microprofile.openapi.annotations.parameters.Parameter; import org.eclipse.microprofile.openapi.annotations.parameters.RequestBody; import org.eclipse.microprofile.openapi.annotations.responses.APIResponse; +import org.eclipse.microprofile.openapi.annotations.responses.APIResponses; import org.keycloak.events.admin.OperationType; import org.keycloak.models.Constants; import org.keycloak.models.KeycloakSession; + public class TenantsResource extends AbstractAdminResource { public TenantsResource(KeycloakSession session) { @@ -36,12 +38,16 @@ public TenantsResource(KeycloakSession session) { @Consumes(MediaType.APPLICATION_JSON) @Produces(MediaType.APPLICATION_JSON) @Operation(operationId = "createTenant", summary = "Create a tenant") - @APIResponse(responseCode = "201", description = "Created") + @APIResponses({ + @APIResponse(responseCode = "201", description = "Created"), + @APIResponse(responseCode = "401", description = "Unauthorized"), + @APIResponse(responseCode = "403", description = "Forbidden") + }) public Response createTenant(@RequestBody(required = true) TenantRepresentation request) { var requiredRole = realm.getAttribute("requiredRoleForTenantCreation"); if (requiredRole != null && !auth.hasAppRole(auth.getClient(), requiredRole)) { - throw new NotAuthorizedException(String.format("Missing required role for tenant creation: %s", requiredRole)); + throw new ForbiddenException(String.format("Missing required role for tenant creation: %s", requiredRole)); } TenantModel model = tenantProvider.createTenant(realm, request.getName(), auth.getUser()); @@ -58,7 +64,10 @@ public Response createTenant(@RequestBody(required = true) TenantRepresentation @GET @Produces(MediaType.APPLICATION_JSON) @Operation(operationId = "listTenants", summary = "List tenants") - @APIResponse(responseCode = "200", description = "OK", content = @Content(schema = @Schema(type = SchemaType.ARRAY, implementation = TenantRepresentation.class))) + @APIResponses({ + @APIResponse(responseCode = "200", description = "OK", content = @Content(schema = @Schema(type = SchemaType.ARRAY, implementation = TenantRepresentation.class))), + @APIResponse(responseCode = "401", description = "Unauthorized") + }) public Stream listTenants( @Parameter(description = "Tenant name") @QueryParam("search") String searchQuery, @Parameter(description = "Pagination offset") @QueryParam("first") Integer firstResult, @@ -79,7 +88,7 @@ public TenantResource getTenantResource(@PathParam("tenantId") String tenantId) TenantModel model = tenantProvider.getTenantById(realm, tenantId) .orElseThrow(() -> new NotFoundException(String.format("%s not found", tenantId))); if (!auth.isTenantAdmin(model)) { - throw new NotAuthorizedException(String.format("Insufficient permission to access %s", tenantId)); + throw new ForbiddenException(String.format("Insufficient permission to access %s", tenantId)); } else { return new TenantResource(this, model); }