-
Notifications
You must be signed in to change notification settings - Fork 24
Add signed batch machine run context / ADR #1772
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
base: develop
Are you sure you want to change the base?
Changes from all commits
efbd26f
c7833ea
aec1c95
db72d62
6b821f8
bc40f8b
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,159 @@ | ||
| package cwms.cda.security; | ||
|
|
||
| import com.fasterxml.jackson.databind.ObjectMapper; | ||
| import com.google.common.flogger.FluentLogger; | ||
| import cwms.cda.ApiServlet; | ||
| import cwms.cda.datasource.ConnectionPreparingDataSource; | ||
| import cwms.cda.datasource.ConnectionPreparer; | ||
| import cwms.cda.datasource.DelegatingConnectionPreparer; | ||
| import cwms.cda.datasource.SessionOfficePreparer; | ||
| import io.javalin.http.Context; | ||
| import io.jsonwebtoken.Claims; | ||
| import io.jsonwebtoken.ExpiredJwtException; | ||
| import io.jsonwebtoken.JwtException; | ||
| import io.jsonwebtoken.Jwts; | ||
| import io.jsonwebtoken.security.Keys; | ||
| import java.io.IOException; | ||
| import java.nio.charset.StandardCharsets; | ||
| import java.security.Key; | ||
| import java.util.Base64; | ||
| import java.util.Locale; | ||
| import java.util.Map; | ||
| import javax.sql.DataSource; | ||
| import javax.servlet.http.HttpServletResponse; | ||
|
|
||
| public final class BatchJobContext { | ||
| private static final FluentLogger logger = FluentLogger.forEnclosingClass(); | ||
| private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); | ||
|
|
||
| public static final String HEADER = "X-CWMS-Job-Context"; | ||
| public static final String RUN_AS_OFFICE_ATTR = "BatchRunAsOffice"; | ||
|
|
||
| public static final String SECRET_PROPERTY = "cwms.dataapi.batch.jobContext.secret"; | ||
| public static final String PREVIOUS_SECRET_PROPERTY = "cwms.dataapi.batch.jobContext.previousSecret"; | ||
| public static final String KEY_ID_PROPERTY = "cwms.dataapi.batch.jobContext.keyId"; | ||
| public static final String ISSUER_PROPERTY = "cwms.dataapi.batch.jobContext.issuer"; | ||
| public static final String AUDIENCE_PROPERTY = "cwms.dataapi.batch.jobContext.audience"; | ||
| public static final String MACHINE_USERS_PROPERTY = "cwms.dataapi.batch.machineUsers"; | ||
|
|
||
| private static final String DEFAULT_ISSUER = "cwms-batch-events"; | ||
| private static final String DEFAULT_AUDIENCE = "cwms-data-api"; | ||
| private static final String DEFAULT_MACHINE_USERS = ""; | ||
|
|
||
| private BatchJobContext() { | ||
| } | ||
|
|
||
| public static boolean isBatchMachineUser(String username) { | ||
| if (username == null) { | ||
| return false; | ||
| } | ||
| String machineUsers = readSetting(MACHINE_USERS_PROPERTY, DEFAULT_MACHINE_USERS); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. CDA should not need to know anything about this, the JWT provided by Keycloak can embed a claim of "machine-auth" or something and decisions made from that. |
||
| if (machineUsers.isBlank()) { | ||
| return false; | ||
| } | ||
| for (String machineUser : machineUsers.split(",")) { | ||
| if (username.equalsIgnoreCase(machineUser.trim())) { | ||
| return true; | ||
| } | ||
| } | ||
| return false; | ||
| } | ||
|
|
||
| public static void prepareContext(Context ctx, DataApiPrincipal principal) throws CwmsAuthException { | ||
| if (!isBatchMachineUser(principal.getName())) { | ||
| return; | ||
| } | ||
|
|
||
| String token = ctx.header(HEADER); | ||
| if (token == null || token.isBlank()) { | ||
| throw new CwmsAuthException("Batch machine request missing signed job context", | ||
| HttpServletResponse.SC_UNAUTHORIZED); | ||
| } | ||
|
|
||
| try { | ||
| Claims claims = parse(token); | ||
| String office = claims.get("run_as_office", String.class); | ||
| if (office == null || office.isBlank()) { | ||
| office = claims.get("office", String.class); | ||
| } | ||
| if (office == null || office.isBlank()) { | ||
| throw new CwmsAuthException("Batch job context missing run_as_office", | ||
| HttpServletResponse.SC_UNAUTHORIZED); | ||
| } | ||
| ctx.attribute(RUN_AS_OFFICE_ATTR, office.toUpperCase(Locale.ROOT)); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. While appropriate to put in to a future logging context... these shouldn't need to be part of the Request/Response attributes. Downstream components needing to know that is a definite code smell. |
||
| } catch (ExpiredJwtException ex) { | ||
| logger.atFine().withCause(ex).log("Batch job context token expired."); | ||
| throw new CwmsAuthException("Batch job context token expired", ex, | ||
| HttpServletResponse.SC_UNAUTHORIZED); | ||
| } catch (JwtException | IllegalArgumentException ex) { | ||
| logger.atFine().withCause(ex).log("Batch job context token validation failed."); | ||
| throw new CwmsAuthException("Batch job context token not valid", ex, | ||
| HttpServletResponse.SC_UNAUTHORIZED); | ||
| } | ||
| } | ||
|
|
||
| public static void applyRunContext(Context ctx) { | ||
| String runAsOffice = ctx.attribute(RUN_AS_OFFICE_ATTR); | ||
| if (runAsOffice == null || runAsOffice.isBlank()) { | ||
| return; | ||
| } | ||
|
|
||
| DataSource dataSource = ctx.attribute(ApiServlet.DATA_SOURCE); | ||
| ConnectionPreparer officePreparer = new SessionOfficePreparer(runAsOffice); | ||
| if (dataSource instanceof ConnectionPreparingDataSource) { | ||
| ConnectionPreparingDataSource preparingDataSource = (ConnectionPreparingDataSource) dataSource; | ||
| preparingDataSource.setPreparer(new DelegatingConnectionPreparer( | ||
| preparingDataSource.getPreparer(), officePreparer)); | ||
| } else { | ||
| ctx.attribute(ApiServlet.DATA_SOURCE, | ||
| new ConnectionPreparingDataSource(officePreparer, dataSource)); | ||
| } | ||
| } | ||
|
|
||
| private static Claims parse(String token) { | ||
| String secret = secretForToken(token); | ||
| if (secret.length() < 32) { | ||
| throw new IllegalArgumentException("Batch job context secret must be at least 32 characters"); | ||
| } | ||
| Key key = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8)); | ||
| return Jwts.parserBuilder() | ||
| .requireIssuer(readSetting(ISSUER_PROPERTY, DEFAULT_ISSUER)) | ||
| .requireAudience(readSetting(AUDIENCE_PROPERTY, DEFAULT_AUDIENCE)) | ||
| .setSigningKey(key) | ||
| .build() | ||
| .parseClaimsJws(token) | ||
| .getBody(); | ||
| } | ||
|
|
||
| private static String secretForToken(String token) { | ||
| String expectedKeyId = readSetting(KEY_ID_PROPERTY, "current"); | ||
| String keyId = keyIdForToken(token); | ||
| if (keyId == null || keyId.isBlank() || expectedKeyId.equals(keyId)) { | ||
| return readSetting(SECRET_PROPERTY, ""); | ||
| } | ||
| if ("previous".equals(keyId)) { | ||
| return readSetting(PREVIOUS_SECRET_PROPERTY, ""); | ||
| } | ||
| throw new IllegalArgumentException("Batch job context key id is not recognized"); | ||
| } | ||
|
|
||
| private static String keyIdForToken(String token) { | ||
| String[] parts = token.split("\\."); | ||
| if (parts.length != 3) { | ||
| throw new IllegalArgumentException("Batch job context token is malformed"); | ||
| } | ||
| try { | ||
| byte[] headerBytes = Base64.getUrlDecoder().decode(parts[0]); | ||
| Map<?, ?> header = OBJECT_MAPPER.readValue(headerBytes, Map.class); | ||
| Object keyId = header.get("kid"); | ||
| return keyId instanceof String ? (String) keyId : null; | ||
| } catch (IllegalArgumentException | IOException e) { | ||
| throw new IllegalArgumentException("Batch job context token header is malformed", e); | ||
| } | ||
| } | ||
|
|
||
| private static String readSetting(String key, String defaultValue) { | ||
| String value = System.getProperty(key, System.getenv(key)); | ||
| return value == null || value.isBlank() ? defaultValue : value; | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -105,12 +105,20 @@ private DataApiPrincipal getUserFromToken(Context ctx) throws CwmsAuthException | |
| AuthDao dao = AuthDao.getInstance(JooqDao.getDslContext(ctx), ctx.attribute(ApiServlet.OFFICE_ID)); | ||
| Optional<DataApiPrincipal> principal = dao.getPrincipalFromPrincipal(oidcPrincipal); | ||
| if (principal.isPresent()) { | ||
| return principal.get(); | ||
| DataApiPrincipal dataApiPrincipal = principal.get(); | ||
| BatchJobContext.prepareContext(ctx, dataApiPrincipal); | ||
| return dataApiPrincipal; | ||
| } else if (CREATE_USERS) { | ||
| final String preferredUserName = claims.get(PREFERRED_USERNAME_CLAIM, String.class); | ||
| if (BatchJobContext.isBatchMachineUser(preferredUserName)) { | ||
| throw new CwmsAuthException("Batch machine principal is not registered", | ||
| HttpServletResponse.SC_UNAUTHORIZED); | ||
| } | ||
| final String givenName = claims.get(GIVEN_NAME_CLAIM, String.class); | ||
| final String email = claims.get(EMAIL_CLAIM, String.class); | ||
| return dao.createUser(preferredUserName, oidcPrincipal, givenName, email); | ||
| DataApiPrincipal dataApiPrincipal = dao.createUser(preferredUserName, oidcPrincipal, givenName, email); | ||
| BatchJobContext.prepareContext(ctx, dataApiPrincipal); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If the "batch" user is getting created randomly here, we have an issue, In the context of a batch process this should be a failure.
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Corrected in bc40f8b |
||
| return dataApiPrincipal; | ||
| } else { | ||
| throw new CwmsAuthException("Not Authorized",HttpServletResponse.SC_UNAUTHORIZED); | ||
| } | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
why is Batch becoming an issuer of secrets?
My understanding is that Keycloak would provide the JWT and CDA would just consume it.