diff --git a/cwms-data-api/src/main/java/cwms/cda/api/TimeSeriesController.java b/cwms-data-api/src/main/java/cwms/cda/api/TimeSeriesController.java index c5cba7c20..4642671c8 100644 --- a/cwms-data-api/src/main/java/cwms/cda/api/TimeSeriesController.java +++ b/cwms-data-api/src/main/java/cwms/cda/api/TimeSeriesController.java @@ -59,6 +59,8 @@ import cwms.cda.data.dto.TimeSeries; import cwms.cda.formatters.ContentType; import cwms.cda.formatters.Formats; +import cwms.cda.helpers.AuthorizationContextHelper; +import cwms.cda.helpers.AuthorizationFilterHelper; import cwms.cda.helpers.DateUtils; import io.javalin.apibuilder.CrudHandler; import io.javalin.core.util.Header; @@ -448,6 +450,14 @@ public void getAll(@NotNull Context ctx) { try (final Timer.Context ignored = markAndTime(GET_ALL)) { DSLContext dsl = getDslContext(ctx); + AuthorizationContextHelper authHelper = new AuthorizationContextHelper(ctx); + AuthorizationFilterHelper authFilter = new AuthorizationFilterHelper(ctx); + + if (authHelper.isAuthorizationHeaderPresent()) { + logger.log(Level.INFO, "Authorization context - User: {0}, Offices: {1}, Roles: {2}", + new Object[]{authHelper.getUsername(), authHelper.getOffices(), authHelper.getRoles()}); + } + TimeSeriesDao dao = getTimeSeriesDao(dsl); String format = ctx.queryParamAsClass(FORMAT, String.class).getOrDefault(""); String names = requiredParam(ctx, NAME); @@ -494,6 +504,14 @@ public void getAll(@NotNull Context ctx) { if (version != null && version.equals("2")) { String office = requiredParam(ctx, OFFICE); + + if (authHelper.isAuthorizationHeaderPresent() && !authHelper.hasOfficeAccess(office)) { + String errorMsg = String.format("User %s does not have access to office %s. Allowed offices: %s", + authHelper.getUsername(), office, authHelper.getOffices()); + logger.log(Level.WARNING, errorMsg); + throw new IllegalArgumentException(errorMsg); + } + TimeSeriesRequestParameters requestParameters = new TimeSeriesRequestParameters.Builder() .withNames(names) .withOffice(office) @@ -504,7 +522,14 @@ public void getAll(@NotNull Context ctx) { .withShouldTrim(trim.getOrDefault(true)) .withIncludeEntryDate(includeEntryDate) .build(); - TimeSeries ts = dao.getTimeseries(cursor, pageSize, requestParameters); + + TimeSeries ts; + if (dao instanceof TimeSeriesDaoImpl && authFilter.hasAuthorizationContext()) { + ts = ((TimeSeriesDaoImpl) dao).getRequestedTimeSeries(cursor, pageSize, requestParameters, null, authFilter); + logger.log(Level.FINE, "Applied authorization filtering at DAO level"); + } else { + ts = dao.getTimeseries(cursor, pageSize, requestParameters); + } if(datum != null) { //this will be null for non-elevation ts // user has requested a specific vertical datum @@ -535,6 +560,19 @@ public void getAll(@NotNull Context ctx) { } String office = ctx.queryParam(OFFICE); + + if (authHelper.isAuthorizationHeaderPresent()) { + if (office == null || office.isEmpty()) { + office = authHelper.buildOfficeFilter(); + logger.log(Level.INFO, "No office specified, applying user office filter: {0}", office); + } else if (!authHelper.hasOfficeAccess(office)) { + String errorMsg = String.format("User %s does not have access to office %s. Allowed offices: %s", + authHelper.getUsername(), office, authHelper.getOffices()); + logger.log(Level.WARNING, errorMsg); + throw new IllegalArgumentException(errorMsg); + } + } + results = dao.getTimeseries(format, names, office, units, datum, beginZdt, endZdt, tz); ctx.status(HttpServletResponse.SC_OK); ctx.result(results); diff --git a/cwms-data-api/src/main/java/cwms/cda/data/dao/TimeSeriesDaoImpl.java b/cwms-data-api/src/main/java/cwms/cda/data/dao/TimeSeriesDaoImpl.java index 5c9d76eee..7caa91f93 100644 --- a/cwms-data-api/src/main/java/cwms/cda/data/dao/TimeSeriesDaoImpl.java +++ b/cwms-data-api/src/main/java/cwms/cda/data/dao/TimeSeriesDaoImpl.java @@ -45,6 +45,7 @@ import cwms.cda.data.dto.catalog.CatalogEntry; import cwms.cda.data.dto.catalog.TimeseriesCatalogEntry; import cwms.cda.formatters.xml.XMLv1; +import cwms.cda.helpers.AuthorizationFilterHelper; import java.math.BigDecimal; import java.math.BigInteger; import java.sql.SQLException; @@ -247,6 +248,11 @@ public FilteredTimeSeries getTimeseries(String page, int pageSize, TimeSeriesReq protected TimeSeries getRequestedTimeSeries(String page, int pageSize, @NotNull TimeSeriesRequestParameters requestParameters, @Nullable FilteredTimeSeriesParameters fp) { + return getRequestedTimeSeries(page, pageSize, requestParameters, fp, null); + } + + protected TimeSeries getRequestedTimeSeries(String page, int pageSize, @NotNull TimeSeriesRequestParameters requestParameters, + @Nullable FilteredTimeSeriesParameters fp, @Nullable AuthorizationFilterHelper authFilter) { String names = requestParameters.getNames(); String office = requestParameters.getOffice(); @@ -378,6 +384,19 @@ protected TimeSeries getRequestedTimeSeries(String page, int pageSize, @NotNull filterConditions = getFilterCondition(fp, resolver); } + if (authFilter != null && authFilter.hasAuthorizationContext()) { + Field officeField = valid.field("office_id", String.class); + + Condition embargoFilter = authFilter.getEmbargoFilter(dataEntryDate, officeField, office); + filterConditions = filterConditions.and(embargoFilter); + logger.log(Level.FINE, "Applied embargo filter to timeseries query"); + + Condition timeWindowFilter = authFilter.getTimeWindowFilter(dataEntryDate, + Timestamp.from(beginTime.toInstant())); + filterConditions = filterConditions.and(timeWindowFilter); + logger.log(Level.FINE, "Applied time window filter to timeseries query"); + } + Field totalField; if (total != null) { totalField = DSL.val(total).as("TOTAL"); diff --git a/cwms-data-api/src/main/java/cwms/cda/helpers/AuthorizationContextHelper.java b/cwms-data-api/src/main/java/cwms/cda/helpers/AuthorizationContextHelper.java new file mode 100644 index 000000000..72af4331d --- /dev/null +++ b/cwms-data-api/src/main/java/cwms/cda/helpers/AuthorizationContextHelper.java @@ -0,0 +1,159 @@ +package cwms.cda.helpers; + +import com.fasterxml.jackson.databind.ObjectMapper; +import io.javalin.http.Context; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.logging.Level; +import java.util.logging.Logger; + +public class AuthorizationContextHelper { + private static final Logger LOGGER = Logger.getLogger(AuthorizationContextHelper.class.getName()); + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + private static final String AUTH_CONTEXT_HEADER = "x-cwms-auth-context"; + + private final Map authContext; + private final Map userContext; + private final Map constraints; + + public AuthorizationContextHelper(Context ctx) { + this.authContext = parseAuthContextHeader(ctx); + this.userContext = extractUserContext(authContext); + this.constraints = extractConstraints(authContext); + } + + private Map parseAuthContextHeader(Context ctx) { + String headerValue = ctx.header(AUTH_CONTEXT_HEADER); + if (headerValue == null || headerValue.isEmpty()) { + LOGGER.log(Level.FINE, "No authorization context header found"); + return Collections.emptyMap(); + } + + try { + return OBJECT_MAPPER.readValue(headerValue, Map.class); + } catch (Exception e) { + LOGGER.log(Level.WARNING, "Failed to parse authorization context header", e); + return Collections.emptyMap(); + } + } + + @SuppressWarnings("unchecked") + private Map extractUserContext(Map authContext) { + if (authContext.containsKey("user")) { + return (Map) authContext.get("user"); + } + return Collections.emptyMap(); + } + + @SuppressWarnings("unchecked") + private Map extractConstraints(Map authContext) { + if (authContext.containsKey("constraints")) { + return (Map) authContext.get("constraints"); + } + return Collections.emptyMap(); + } + + public String getUserId() { + return (String) userContext.getOrDefault("id", null); + } + + public String getUsername() { + return (String) userContext.getOrDefault("username", null); + } + + public String getEmail() { + return (String) userContext.getOrDefault("email", null); + } + + @SuppressWarnings("unchecked") + public List getRoles() { + Object roles = userContext.get("roles"); + if (roles instanceof List) { + return (List) roles; + } + return Collections.emptyList(); + } + + @SuppressWarnings("unchecked") + public List getOffices() { + Object offices = userContext.get("offices"); + if (offices instanceof List) { + return (List) offices; + } + return Collections.emptyList(); + } + + public String getPrimaryOffice() { + return (String) userContext.getOrDefault("primary_office", null); + } + + public String getPersona() { + return (String) userContext.getOrDefault("persona", null); + } + + public String getRegion() { + return (String) userContext.getOrDefault("region", null); + } + + public String getAllowedOfficesConstraint() { + return (String) constraints.getOrDefault("allowed_offices", null); + } + + public boolean isEmbargoExempt() { + Object exempt = constraints.get("embargo_exempt"); + return exempt != null && (boolean) exempt; + } + + public String getTimezone() { + return (String) constraints.getOrDefault("timezone", null); + } + + public boolean hasRole(String role) { + return getRoles().contains(role); + } + + public boolean hasOfficeAccess(String office) { + if (office == null) { + return false; + } + List userOffices = getOffices(); + return userOffices.contains(office); + } + + public String buildOfficeFilter() { + String allowedOffices = getAllowedOfficesConstraint(); + if (allowedOffices != null && !allowedOffices.isEmpty()) { + if ("*".equals(allowedOffices)) { + return null; + } + return allowedOffices; + } + + List offices = getOffices(); + if (offices.isEmpty()) { + return null; + } + + return String.join(",", offices); + } + + public boolean isAuthorizationHeaderPresent() { + return !authContext.isEmpty(); + } + + public Map getFullContext() { + return Collections.unmodifiableMap(authContext); + } + + @Override + public String toString() { + return "AuthorizationContextHelper{" + + "userId='" + getUserId() + '\'' + + ", username='" + getUsername() + '\'' + + ", offices=" + getOffices() + + ", roles=" + getRoles() + + ", persona='" + getPersona() + '\'' + + '}'; + } +} diff --git a/cwms-data-api/src/main/java/cwms/cda/helpers/AuthorizationFilterHelper.java b/cwms-data-api/src/main/java/cwms/cda/helpers/AuthorizationFilterHelper.java new file mode 100644 index 000000000..76a6995fb --- /dev/null +++ b/cwms-data-api/src/main/java/cwms/cda/helpers/AuthorizationFilterHelper.java @@ -0,0 +1,203 @@ +package cwms.cda.helpers; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.jooq.Condition; +import org.jooq.Field; +import org.jooq.impl.DSL; + +import java.sql.Timestamp; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.ArrayList; +import java.util.List; +import java.util.logging.Level; +import java.util.logging.Logger; + +/** + * Applies authorization filtering constraints from x-cwms-auth-context header to database queries. + * Generates JOOQ conditions for office filtering, embargo rules, time windows, and data classification. + */ +public class AuthorizationFilterHelper { + + private static final Logger logger = Logger.getLogger(AuthorizationFilterHelper.class.getName()); + private static final ObjectMapper mapper = new ObjectMapper(); + + private final JsonNode constraints; + private final boolean hasAuthContext; + + public AuthorizationFilterHelper(io.javalin.http.Context ctx) { + JsonNode constraintsNode = null; + boolean hasContext = false; + + try { + String authHeader = ctx.header("x-cwms-auth-context"); + if (authHeader != null && !authHeader.isEmpty()) { + JsonNode authContext = mapper.readTree(authHeader); + constraintsNode = authContext.get("constraints"); + hasContext = true; + + logger.log(Level.FINE, "Authorization context loaded with constraints: {0}", + constraintsNode != null ? constraintsNode.toString() : "none"); + } + } catch (Exception e) { + logger.log(Level.WARNING, "Failed to parse authorization context", e); + } + + this.constraints = constraintsNode; + this.hasAuthContext = hasContext; + } + + public AuthorizationFilterHelper(JsonNode constraints) { + this.constraints = constraints; + this.hasAuthContext = constraints != null; + } + + public boolean hasAuthorizationContext() { + return hasAuthContext; + } + + public Condition getOfficeFilter(Field officeField, String requestedOffice) { + if (constraints == null || !constraints.has("allowed_offices")) { + return DSL.noCondition(); + } + + JsonNode allowedOfficesNode = constraints.get("allowed_offices"); + List allowedOffices = new ArrayList<>(); + + if (allowedOfficesNode.isArray()) { + for (JsonNode office : allowedOfficesNode) { + allowedOffices.add(office.asText()); + } + } + + if (allowedOffices.contains("*")) { + logger.log(Level.FINE, "User has access to all offices"); + return DSL.noCondition(); + } + + if (allowedOffices.isEmpty()) { + logger.log(Level.WARNING, "User has no allowed offices - denying all access"); + return DSL.falseCondition(); + } + + // User requested a specific office + if (requestedOffice != null && !requestedOffice.isEmpty()) { + if (!allowedOffices.contains(requestedOffice)) { + logger.log(Level.WARNING, "User not authorized for office: {0}", requestedOffice); + return DSL.falseCondition(); + } + return officeField.eq(requestedOffice); + } + + // Filter to user's allowed offices + logger.log(Level.FINE, "Filtering to allowed offices: {0}", allowedOffices); + return officeField.in(allowedOffices); + } + + public Condition getEmbargoFilter(Field timestampField, Field officeField, String requestedOffice) { + if (constraints == null) { + return DSL.noCondition(); + } + + boolean embargoExempt = constraints.has("embargo_exempt") && + constraints.get("embargo_exempt").asBoolean(); + + if (embargoExempt) { + logger.log(Level.FINE, "User is exempt from embargo rules"); + return DSL.noCondition(); + } + + JsonNode embargoRulesNode = constraints.get("embargo_rules"); + if (embargoRulesNode == null || embargoRulesNode.isNull()) { + logger.log(Level.FINE, "No embargo rules present"); + return DSL.noCondition(); + } + + if (requestedOffice != null && embargoRulesNode.has(requestedOffice)) { + int embargoHours = embargoRulesNode.get(requestedOffice).asInt(); + Timestamp cutoff = Timestamp.from(Instant.now().minus(embargoHours, ChronoUnit.HOURS)); + logger.log(Level.FINE, "Applying {0} embargo: {1} hours (data before {2})", + new Object[]{requestedOffice, embargoHours, cutoff}); + return timestampField.lessThan(cutoff); + } + + if (embargoRulesNode.has("default")) { + int defaultHours = embargoRulesNode.get("default").asInt(); + Timestamp defaultCutoff = Timestamp.from(Instant.now().minus(defaultHours, ChronoUnit.HOURS)); + logger.log(Level.FINE, "Applying default embargo: {0} hours (data before {1})", + new Object[]{defaultHours, defaultCutoff}); + return timestampField.lessThan(defaultCutoff); + } + + return DSL.noCondition(); + } + + public Condition getTimeWindowFilter(Field timestampField, Timestamp userRequestedBeginTime) { + if (constraints == null || !constraints.has("time_window")) { + return DSL.noCondition(); + } + + JsonNode timeWindowNode = constraints.get("time_window"); + if (timeWindowNode.isNull() || !timeWindowNode.has("restrict_hours")) { + return DSL.noCondition(); + } + + int restrictHours = timeWindowNode.get("restrict_hours").asInt(); + Timestamp cutoffTime = Timestamp.from(Instant.now().minus(restrictHours, ChronoUnit.HOURS)); + + logger.log(Level.INFO, "Applying time window restriction: {0} hours (data after {1})", + new Object[]{restrictHours, cutoffTime}); + + // Override user's requested time if it's outside the allowed window + if (userRequestedBeginTime == null || userRequestedBeginTime.before(cutoffTime)) { + return timestampField.greaterOrEqual(cutoffTime); + } + + // User's request is within allowed window + return timestampField.greaterOrEqual(userRequestedBeginTime); + } + + public Condition getClassificationFilter(Field classificationField) { + if (constraints == null || !constraints.has("data_classification")) { + return DSL.noCondition(); + } + + JsonNode classificationNode = constraints.get("data_classification"); + List allowedClassifications = new ArrayList<>(); + + if (classificationNode.isArray()) { + for (JsonNode classification : classificationNode) { + allowedClassifications.add(classification.asText()); + } + } + + if (allowedClassifications.isEmpty()) { + logger.log(Level.WARNING, "No allowed classifications - denying all access"); + return DSL.falseCondition(); + } + + logger.log(Level.FINE, "Filtering to allowed classifications: {0}", allowedClassifications); + return DSL.or( + classificationField.in(allowedClassifications), + classificationField.isNull() // Allow data with no classification set + ); + } + + public Condition getAllFilters( + Field officeField, + Field timestampField, + Field classificationField, + String requestedOffice, + Timestamp userRequestedBeginTime) { + + Condition officeFilter = getOfficeFilter(officeField, requestedOffice); + Condition embargoFilter = getEmbargoFilter(timestampField, officeField, requestedOffice); + Condition timeWindowFilter = getTimeWindowFilter(timestampField, userRequestedBeginTime); + Condition classificationFilter = classificationField != null + ? getClassificationFilter(classificationField) + : DSL.noCondition(); + + return DSL.and(officeFilter, embargoFilter, timeWindowFilter, classificationFilter); + } +}