Skip to content

Feature/1092 1098 basic user management #1115

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 21 commits into from
Jun 24, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
34ba0f5
Initial layout of user/role management controllers.
MikeNeilson May 21, 2025
8cb8a91
Initial structure of the endpoints set.
MikeNeilson May 23, 2025
db6df3e
Initial structure of the endpoints set.
MikeNeilson May 23, 2025
6e544de
Move cac auth flag to own variable.
MikeNeilson May 28, 2025
4971c5b
Roles can be added and removed.
MikeNeilson May 28, 2025
42cd77a
Able to retrieve roles.
MikeNeilson May 28, 2025
76f98d8
WIP on get all users.
MikeNeilson May 28, 2025
a47888e
Prelimiary Operations complete.
MikeNeilson May 29, 2025
9c42037
Add test to verify unauthorized access to user list fails.
MikeNeilson May 29, 2025
c2984ca
Rename controller for user data.
MikeNeilson Jun 4, 2025
8871714
Additional logging for ATO requirements.
MikeNeilson Jun 4, 2025
1535305
Merge branch 'develop' into feature/1092-1098-basic-user-management
MikeNeilson Jun 4, 2025
b551974
Merge branch 'develop' into feature/1092-1098-basic-user-management
MikeNeilson Jun 11, 2025
931899c
Merge branch 'develop' into feature/1092-1098-basic-user-management
MikeNeilson Jun 11, 2025
12f0d9d
Merge branch 'develop' into feature/1092-1098-basic-user-management
MikeNeilson Jun 16, 2025
6c70118
initial PR review updates.
MikeNeilson Jun 11, 2025
507b115
Users get all cursor behaving correctly, still no roles returned.
MikeNeilson Jun 16, 2025
68cd749
Add original 'limit' office to cursor, rebuild the where clause with …
MikeNeilson Jun 17, 2025
313eb39
Handle null limiit office in cursor correctly.
MikeNeilson Jun 20, 2025
3437548
Merge branch 'develop' into feature/1092-1098-basic-user-management
MikeNeilson Jun 24, 2025
7e811ff
Merge branch 'develop' into feature/1092-1098-basic-user-management
MikeNeilson Jun 24, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions compose_files/sql/users.sql
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,12 @@ begin
cwms_sec.add_user_to_group('m5hectest','All Users', 'SWT');
cwms_sec.add_user_to_group('m5hectest','CWMS Users', 'SWT');
execute immediate 'grant execute on cwms_20.cwms_upass to web_user';


cwms_sec.add_user_cwms('m5testadmin', NULL, 'LRL');
cwms_sec.add_user_to_group('m5testadmin','All Users', 'LRL');
cwms_sec.add_user_to_group('m5testadmin','CWMS Users', 'LRL');
cwms_sec.add_user_to_group('m5testadmin','CWMS User Admins', 'LRL');
end;
/
quit;
24 changes: 23 additions & 1 deletion cwms-data-api/src/main/java/cwms/cda/ApiServlet.java
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,11 @@
import cwms.cda.api.UnitsController;
import cwms.cda.api.UpstreamLocationsGetController;
import cwms.cda.api.auth.ApiKeyController;
import cwms.cda.api.auth.users.UserProfileController;
import cwms.cda.api.auth.users.UsersController;
import cwms.cda.api.auth.users.roles.AddRoleController;
import cwms.cda.api.auth.users.roles.DeleteRolesController;
import cwms.cda.api.auth.users.roles.GetRolesController;
import cwms.cda.api.enums.UnitSystem;
import cwms.cda.api.errors.AlreadyExists;
import cwms.cda.api.errors.CdaError;
Expand Down Expand Up @@ -254,7 +259,10 @@
"/project-lock-rights/*",
"/properties/*",
"/lookup-types/*",
"/embankments/*"
"/embankments/*",
"/user/*",
"/users/*",
"/roles/*"
})
public class ApiServlet extends HttpServlet {

Expand Down Expand Up @@ -676,6 +684,20 @@ protected void configureRoutes() {

addProjectLocksHandlers("/project-locks/{name}", requiredRoles);
addProjectLockRightsHandlers("/project-lock-rights/{project-id}", requiredRoles);


addUserManagementHandlers();
}

private void addUserManagementHandlers() {
RouteRole[] adminRoles = new RouteRole[] { new Role("CWMS User Admins")};
RouteRole[] userRoles = new RouteRole[] {new Role("CWMS Users")};
crud("/users/{user-name}", new UsersController(metrics), adminRoles);
get("/roles", new GetRolesController(metrics), adminRoles);
get("/user/profile", new UserProfileController(metrics), userRoles);
post("/user/{user-name}/roles/{office-id}", new AddRoleController(metrics), adminRoles);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this suggest that a user could have a space in multiple offices?

As opposed to
/user/{office-id}/roles/{user-name}?

I would just think that office would be the base and a user would be requested from that office

This probably doesn't really matter since either way the values get passed in. It's just, to me an office seems like it would come first.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For most of the other data you would be correct, but in this case I don't think so.
The user is a the root of the infrastructure, and permissions to an office data are secondary.
E.g. the user "belongs" to the system as a whole.

Versus say a Time Series which "belongs" to a specific office.

delete("/user/{user-name}/roles/{office-id}", new DeleteRolesController(metrics), adminRoles);

}

private void addRatingHandlers(RouteRole[] requiredRoles) {
Expand Down
2 changes: 2 additions & 0 deletions cwms-data-api/src/main/java/cwms/cda/api/Controllers.java
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,8 @@ public final class Controllers {

public static final String CWMS_OFFICE = "CWMS";

public static final String OFFICE_DESCRIPTION = "Office Identifier 3/4 letter as returned by the office endpoint.";

private static final String DEPRECATED_HEADER = "CWMS-DATA-Format-Deprecated";
private static final String DEPRECATED_TAB = "2024-11-01 TAB is not used often.";
private static final String DEPRECATED_CSV = "2024-11-01 CSV is not used often.";
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
package cwms.cda.api.auth.users;

import static cwms.cda.api.Controllers.STATUS_200;
import static cwms.cda.data.dao.JooqDao.getDslContext;

import java.security.Principal;
import java.util.Optional;

import org.jooq.DSLContext;

import com.codahale.metrics.MetricRegistry;

import cwms.cda.ApiServlet;
import cwms.cda.data.dao.AuthDao;
import cwms.cda.data.dao.UserDao;
import cwms.cda.data.dto.Clobs;
import cwms.cda.data.dto.auth.users.User;
import cwms.cda.formatters.ContentType;
import cwms.cda.formatters.Formats;
import cwms.cda.security.DataApiPrincipal;
import cwms.cda.security.Role;
import io.javalin.core.util.Header;
import io.javalin.http.Context;
import io.javalin.http.Handler;
import io.javalin.plugin.openapi.annotations.HttpMethod;
import io.javalin.plugin.openapi.annotations.OpenApi;
import io.javalin.plugin.openapi.annotations.OpenApiContent;
import io.javalin.plugin.openapi.annotations.OpenApiParam;
import io.javalin.plugin.openapi.annotations.OpenApiResponse;
import io.javalin.plugin.openapi.annotations.OpenApiSecurity;

public class UserProfileController implements Handler {

private final MetricRegistry metrics;

public UserProfileController(MetricRegistry metrics) {
this.metrics = metrics;
}

@OpenApi(
responses = @OpenApiResponse(
content = {
@OpenApiContent(from = User.class, type = Formats.JSON)
},
status = STATUS_200
),
security = {
@OpenApiSecurity(name = "gets overridden allows lock icon.")
},
description = "View users' own information",
method = HttpMethod.GET,
tags = {"User Management"}
)
@Override
public void handle(Context ctx) throws Exception {
DataApiPrincipal p = ctx.attribute(AuthDao.DATA_API_PRINCIPAL);
DSLContext dsl = getDslContext(ctx);
UserDao dao = new UserDao(dsl);
String cac_user = p.getRoles()
.stream()
.filter(r -> r.equals(new Role(ApiServlet.CAC_USER)))
.map(r -> ApiServlet.CAC_USER)
.findFirst().orElse(null);
User user = dao.getByUniqueName(p.getName(), cac_user).orElse(null);
String formatHeader = ctx.header(Header.ACCEPT);
ContentType contentType = Formats.parseHeader(formatHeader, User.class);
String result = Formats.format(contentType, user);

ctx.result(result);
ctx.contentType(contentType.toString());
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
package cwms.cda.api.auth.users;

import static com.codahale.metrics.MetricRegistry.name;
import static cwms.cda.api.Controllers.CURSOR;
import static cwms.cda.api.Controllers.GET_ALL;
import static cwms.cda.api.Controllers.INCLUDE_VALUES;
import static cwms.cda.api.Controllers.OFFICE;
import static cwms.cda.api.Controllers.PAGE;
import static cwms.cda.api.Controllers.PAGE_SIZE;
import static cwms.cda.api.Controllers.STATUS_200;
import static cwms.cda.api.Controllers.STATUS_201;
import static cwms.cda.api.Controllers.STATUS_204;
import static cwms.cda.api.Controllers.markAndTime;
import static cwms.cda.api.Controllers.queryParamAsClass;
import static cwms.cda.data.dao.JooqDao.getDslContext;

import java.util.List;

import org.jooq.DSLContext;

import com.codahale.metrics.MetricRegistry;
import com.codahale.metrics.Timer;

import cwms.cda.ApiServlet;
import cwms.cda.api.ClobController;
import cwms.cda.api.Controllers;
import cwms.cda.api.errors.CdaError;
import cwms.cda.data.dao.UserDao;
import cwms.cda.data.dto.Clobs;
import cwms.cda.data.dto.CwmsDTOPaginated;
import cwms.cda.data.dto.auth.ApiKey;
import cwms.cda.data.dto.auth.users.User;
import cwms.cda.data.dto.auth.users.Users;
import cwms.cda.formatters.ContentType;
import cwms.cda.formatters.Formats;
import cwms.cda.security.Role;
import io.javalin.apibuilder.CrudHandler;
import io.javalin.core.security.RouteRole;
import io.javalin.core.util.Header;
import io.javalin.http.Context;
import io.javalin.http.HttpCode;
import io.javalin.plugin.openapi.annotations.OpenApi;
import io.javalin.plugin.openapi.annotations.OpenApiContent;
import io.javalin.plugin.openapi.annotations.OpenApiParam;
import io.javalin.plugin.openapi.annotations.OpenApiRequestBody;
import io.javalin.plugin.openapi.annotations.OpenApiResponse;
import io.javalin.plugin.openapi.annotations.OpenApiSecurity;

public class UsersController implements CrudHandler {
private final MetricRegistry metrics;
private static final int DEFAULT_PAGE_SIZE = 100;
public static final String TAG = "User Management";

public UsersController(MetricRegistry metrics) {
this.metrics = metrics;

}

private Timer.Context markAndTime(String subject) {
return Controllers.markAndTime(metrics, getClass().getName(), subject);
}

@OpenApi(ignore = true)
@Override
public void create(Context ctx) {
throw new UnsupportedOperationException("Unimplemented method 'create'");
}

@OpenApi(ignore = true)
@Override
public void delete(Context ctx, String username) {
// TODO Auto-generated method stub
throw new UnsupportedOperationException("Unimplemented method 'delete'");
}


@OpenApi(
queryParams = {
@OpenApiParam(allowEmptyValue = true, name = OFFICE, type = String.class,
description = "Show only users with active privileges in a given office."
+ Controllers.OFFICE_DESCRIPTION ),
@OpenApiParam(name = PAGE,
description = "This end point can return a lot of data, this "
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This end point can return a lot of data

I don't know if this needs to be said, but I get the spirit of it

"Lots of data, don't forget about the page option"

+ "identifies where in the request you are. This is an opaque"
+ " value, and can be obtained from the 'next-page' value in "
+ "the response."),
@OpenApiParam(name = PAGE_SIZE,
type = Integer.class,
description = "How many entries per page returned. Default "
+ DEFAULT_PAGE_SIZE + ".")
},
responses = @OpenApiResponse(
content = {
@OpenApiContent(from = Users.class, type = Formats.JSON)
},
status = STATUS_200
),
security = {
@OpenApiSecurity(name = "gets overridden allows lock icon.")
},
description = "View all users",
tags = {TAG}
)
@Override
public void getAll(Context ctx) {
try (final Timer.Context ignored = markAndTime(GET_ALL)) {
DSLContext dsl = getDslContext(ctx);
String office = ctx.queryParam(OFFICE);

String formatHeader = ctx.header(Header.ACCEPT);
ContentType contentType = Formats.parseHeader(formatHeader, Users.class);

String cursor = queryParamAsClass(ctx, new String[]{PAGE, CURSOR},
String.class, "", metrics, name(UsersController.class.getName(), GET_ALL));

if (!CwmsDTOPaginated.CURSOR_CHECK.invoke(cursor)) {
ctx.json(new CdaError("cursor or page passed in but failed validation"))
.status(HttpCode.BAD_REQUEST);
return;
}

int pageSize = queryParamAsClass(ctx, new String[]{PAGE_SIZE}, Integer.class, DEFAULT_PAGE_SIZE, metrics,
name(UsersController.class.getName(), GET_ALL));

boolean includeRoles = queryParamAsClass(ctx, new String[]{"include-roles"},
Boolean.class, false, metrics,
name(UsersController.class.getName(), GET_ALL));
UserDao dao = new UserDao(dsl);
Users users = dao.getAll(cursor, pageSize, office, includeRoles);

String result = Formats.format(contentType, users);

ctx.result(result);
ctx.contentType(contentType.toString());
}
}

@OpenApi(
pathParams = {
@OpenApiParam(name = "user-name", required = true,
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

username or user-name - to hyphen or not to hyphen

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That is the question.

description = "Specific user to retrieve")
},
responses = @OpenApiResponse(
content = {
@OpenApiContent(from = User.class, type = Formats.JSON)
},
status = STATUS_200
),
security = {
@OpenApiSecurity(name = "gets overridden allows lock icon.")
},
description = "View specific user",
tags = {TAG}
)
@Override
public void getOne(Context ctx, String userName) {
DSLContext dsl = getDslContext(ctx);
UserDao dao = new UserDao(dsl);
User user = dao.getByUniqueName(userName, null).orElse(null);
String formatHeader = ctx.header(Header.ACCEPT);
ContentType contentType = Formats.parseHeader(formatHeader, User.class);
String result = Formats.format(contentType, user);

ctx.result(result);
ctx.contentType(contentType.toString());
}

@OpenApi(
ignore = true // users cannot be updated. Rolls are handled by a separate endpoint.
)
@Override
public void update(Context ctx, String arg1) {
throw new UnsupportedOperationException("Unimplemented method 'update'");
}



}
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
package cwms.cda.api.auth.users.roles;

import static cwms.cda.api.Controllers.STATUS_204;
import static cwms.cda.data.dao.JooqDao.getDslContext;

import com.codahale.metrics.MetricRegistry;

import cwms.cda.api.Controllers;
import cwms.cda.data.dao.AuthDao;
import cwms.cda.data.dao.UserDao;
import cwms.cda.formatters.Formats;
import cwms.cda.security.DataApiPrincipal;
import io.javalin.http.Context;
import io.javalin.http.Handler;
import io.javalin.http.HttpCode;
import io.javalin.plugin.openapi.annotations.OpenApi;
import io.javalin.plugin.openapi.annotations.OpenApiContent;
import io.javalin.plugin.openapi.annotations.OpenApiParam;
import io.javalin.plugin.openapi.annotations.OpenApiRequestBody;
import io.javalin.plugin.openapi.annotations.OpenApiResponse;
import io.javalin.plugin.openapi.annotations.OpenApiSecurity;

public class AddRoleController implements Handler {
private final MetricRegistry metrics;

public AddRoleController(MetricRegistry metrics) {
this.metrics = metrics;
}

@OpenApi(
pathParams = {
@OpenApiParam(name = "office-id", required = true,
description = "Office for these roles." + Controllers.OFFICE_DESCRIPTION),
@OpenApiParam(name = "user-name", required = true,
description = "Name of the user to alter")
},
responses = @OpenApiResponse(
status = STATUS_204
),
requestBody = @OpenApiRequestBody(
content = {
@OpenApiContent(from = String[].class, type = Formats.JSON, isArray = true)
}
),
security = {
@OpenApiSecurity(name = "gets overridden allows lock icon.")
},
description = "Add roles to user",
tags = {"User Management"}
)
@Override
public void handle(Context ctx) throws Exception {
final DataApiPrincipal p = ctx.attribute(AuthDao.DATA_API_PRINCIPAL);
final String user = ctx.pathParam("user-name");
final String office = ctx.pathParam("office-id");
final String[] roles = ctx.bodyAsClass(String[].class);
UserDao dao = new UserDao(getDslContext(ctx));
dao.addRoles(p, user, office, roles);
ctx.status(HttpCode.NO_CONTENT);
}

}
Loading
Loading