From 6a6b851e4f01578a3099b4a1da9e40f2bc1346ab Mon Sep 17 00:00:00 2001 From: travon-speller Date: Tue, 23 Jun 2026 12:31:38 -0400 Subject: [PATCH 1/3] feat: add feature-gate db and reactors to control visibility in UI --- docs/feature-gate-design.md | 277 ++++++++ .../auth/utils/AbstractSecurityUtils.java | 83 +++ .../auth/utils/AppFeatureFlagUtils.java | 657 ++++++++++++++++++ src/prerna/auth/utils/SecurityOwlCreator.java | 30 + .../featuregate/CheckFeatureFlagReactor.java | 75 ++ .../CreateAppFeatureFlagReactor.java | 79 +++ .../CreateAppVersionBucketReactor.java | 103 +++ .../DeleteAppFeatureFlagReactor.java | 76 ++ .../DeleteAppVersionBucketReactor.java | 88 +++ .../GetAppFeatureFlagsReactor.java | 71 ++ .../GetAppVersionBucketsReactor.java | 99 +++ .../featuregate/GetUserAppVersionReactor.java | 84 +++ .../GetUserFeatureFlagsReactor.java | 71 ++ .../RemoveUserFromFeatureFlagReactor.java | 82 +++ .../featuregate/SetUserAppVersionReactor.java | 106 +++ .../UpdateAppFeatureFlagReactor.java | 102 +++ .../UpdateAppVersionBucketReactor.java | 93 +++ 17 files changed, 2176 insertions(+) create mode 100644 docs/feature-gate-design.md create mode 100644 src/prerna/auth/utils/AppFeatureFlagUtils.java create mode 100644 src/prerna/reactor/featuregate/CheckFeatureFlagReactor.java create mode 100644 src/prerna/reactor/featuregate/CreateAppFeatureFlagReactor.java create mode 100644 src/prerna/reactor/featuregate/CreateAppVersionBucketReactor.java create mode 100644 src/prerna/reactor/featuregate/DeleteAppFeatureFlagReactor.java create mode 100644 src/prerna/reactor/featuregate/DeleteAppVersionBucketReactor.java create mode 100644 src/prerna/reactor/featuregate/GetAppFeatureFlagsReactor.java create mode 100644 src/prerna/reactor/featuregate/GetAppVersionBucketsReactor.java create mode 100644 src/prerna/reactor/featuregate/GetUserAppVersionReactor.java create mode 100644 src/prerna/reactor/featuregate/GetUserFeatureFlagsReactor.java create mode 100644 src/prerna/reactor/featuregate/RemoveUserFromFeatureFlagReactor.java create mode 100644 src/prerna/reactor/featuregate/SetUserAppVersionReactor.java create mode 100644 src/prerna/reactor/featuregate/UpdateAppFeatureFlagReactor.java create mode 100644 src/prerna/reactor/featuregate/UpdateAppVersionBucketReactor.java diff --git a/docs/feature-gate-design.md b/docs/feature-gate-design.md new file mode 100644 index 00000000000..234d4b70291 --- /dev/null +++ b/docs/feature-gate-design.md @@ -0,0 +1,277 @@ +# Feature Gate Design — App-Scoped + +## Core Concept + +Applications built on SEMOSS can define feature flags scoped to their app. Users are assigned a numeric **version** within each flag. Versions are "buckets" that group users; flags are enabled when a user's version meets or exceeds the flag's `minVersion` threshold, enabling gradual rollout. + +``` +Application (Project) + └─ Feature Flags (e.g. "dark-mode", "new-dashboard") + └─ Version Buckets (v0=disabled, v1=beta, v2=release, v3=stable) + └─ User Assignments per Bucket + └─ Evaluated per User at runtime → true / false +``` + +--- + +## Version Model + +Version is a plain integer assigned per user **per flag per app** (e.g. `1`, `2`, `3`). + +- A flag specifies a **minimum version** (`minVersion`) — any user at that version or above sees the flag as enabled. +- A flag specifies a **default version** (`defaultVersion`) — assigned to users with no explicit assignment. +- This keeps comparisons simple: `userAssignedVersion >= minVersion`. +- Version 0 is conventionally "disabled"; unassigned users default to `defaultVersion`. +- **Per-flag scoping prevents data collision**: If flag A is deleted and flag B is created (even with the same key), they have separate version buckets and user assignments. + +--- + +## Data Model + +Tables in the **Security DB** (H2/RDB). + +```sql +-- Flags defined per application +APP_FEATURE_FLAG ( + FLAG_ID VARCHAR PRIMARY KEY, -- UUID; unique per flag + APP_ID VARCHAR, -- references existing Project + FLAG_KEY VARCHAR, -- e.g. "new-dashboard"; human-readable name + MIN_VERSION INTEGER, -- feature enabled for version >= this + DEFAULT_VERSION INTEGER, -- assigned to users with no explicit version + CREATED_BY VARCHAR, + CREATED_AT TIMESTAMP +) + +-- Version buckets with descriptions; per-flag per-app scoped +APP_VERSION_BUCKET ( + APP_ID VARCHAR, + FLAG_ID VARCHAR, -- scopes bucket to this flag + VERSION INTEGER, + DESCRIPTION VARCHAR, + PRIMARY KEY (APP_ID, FLAG_ID, VERSION) +) + +-- User's assigned version for each flag in each app +APP_USER_VERSION ( + APP_ID VARCHAR, + FLAG_ID VARCHAR, -- scopes assignment to this flag + USER_ID VARCHAR, + VERSION INTEGER, + PRIMARY KEY (APP_ID, FLAG_ID, USER_ID) +) +``` + +**Why flagId, not flagKey?** +Using UUID (flagId) as the storage key prevents data collision: if a flag is deleted and recreated with the same key, it gets a new flagId, so old version buckets and user assignments are not reused. Clients still see human-readable `flagKey` in responses. + +--- + +## Components + +### Flag Management Reactors +App owners use these to create and manage flags. All live under `prerna/reactor/featuregate/`. + +| Reactor | Pixel Call | Description | +|---|---|---| +| `CreateAppFeatureFlagReactor` | `CreateAppFeatureFlag(app, key, description)` | Define a new flag for an app (returns flagId) | +| `UpdateAppFeatureFlagReactor` | `UpdateAppFeatureFlag(app, flagId, minVersion, defaultVersion)` | Set which version enables the flag and default for unassigned users | +| `DeleteAppFeatureFlagReactor` | `DeleteAppFeatureFlag(app, flagId)` | Remove a flag and cascade to version buckets and user assignments | +| `GetAppVersionBucketsReactor` | `GetAppVersionBuckets(app, flagId)` | List all version buckets for a flag with descriptions and user lists | + +### Version Bucket Management Reactors + +| Reactor | Pixel Call | Description | +|---|---|---| +| `CreateAppVersionBucketReactor` | `CreateAppVersionBucket(app, flagId, version, description?)` | Create an empty bucket for a specific version (description optional) | +| `UpdateAppVersionBucketReactor` | `UpdateAppVersionBucket(app, flagId, version, description)` | Update a bucket's description | +| `DeleteAppVersionBucketReactor` | `DeleteAppVersionBucket(app, flagId, version)` | Delete a bucket definition (users assigned to it remain in that version) | + +### User Assignment Reactors + +| Reactor | Pixel Call | Description | +|---|---|---| +| `SetUserAppVersionReactor` | `SetUserAppVersion(app, flagId, users[], version)` | Assign one or more users to a version for this flag | +| `GetUserAppVersionReactor` | `GetUserAppVersion(app, flagId, user)` | Get a user's current version assignment for this flag | +| `RemoveUserFromFeatureFlagReactor` | `RemoveUserFromFeatureFlag(app, flagId, user)` | Remove a user from a flag; they fall back to the flag's defaultVersion | + +### Evaluation Reactors + +| Reactor | Pixel Call | Description | +|---|---|---| +| `CheckFeatureFlagReactor` | `CheckFeatureFlag(app, flagId)` | Evaluate: does current user have flag enabled? Returns boolean | +| `GetUserFeatureFlagsReactor` | `GetUserFeatureFlags(app)` | Returns all flags (by flagKey) and their evaluated state for the current user — app can handle checks client-side | + +### Evaluation Logic — `AppFeatureFlagUtils.java` + +``` +prerna/auth/utils/AppFeatureFlagUtils.java + +evaluate(appId, flagId, userId): + 1. Look up flag: fetch minVersion and defaultVersion + 2. Look up user's assigned version in APP_USER_VERSION for this (app, flag, user) + 3. If no assignment found, use flag's defaultVersion + 4. Compare: userVersion >= minVersion + 5. Return true if comparison holds, else false +``` + +**Per-flag scoping:** Every query includes both `flagId` and `appId` in the WHERE clause, ensuring users from one flag don't leak into another. + +--- + +## Evaluation Flow + +``` +App's Pixel script + └─ CheckFeatureFlag(app, flagId) + └─ AppFeatureFlagUtils.evaluate(appId, flagId, currentUser) + ├─ Lookup flag (minVersion, defaultVersion) + ├─ Lookup user's version for this flag (APP_USER_VERSION) + ├─ Compare: userVersion >= minVersion + └─ Return boolean → app shows or hides the feature +``` + +--- + +## Example Usage + +``` +// --- Setup phase 1: Create the flag --- + +CreateAppFeatureFlag( + app = "myApp", + key = "new-dashboard", + description = "Redesigned dashboard UI" +) +// Returns: flagId = "550e8400-e29b-41d4-a716-446655440000" + + +// --- Setup phase 2: Define version buckets (optional; auto-created on first assignment) --- + +CreateAppVersionBucket( + app = "myApp", + flagId = "550e8400-e29b-41d4-a716-446655440000", + version = 1, + description = "Beta testers" +); + +CreateAppVersionBucket( + app = "myApp", + flagId = "550e8400-e29b-41d4-a716-446655440000", + version = 2, + description = "General release" +); + + +// --- Setup phase 3: Configure flag evaluation --- + +UpdateAppFeatureFlag( + app = "myApp", + flagId = "550e8400-e29b-41d4-a716-446655440000", + minVersion = 2, // flag enabled for v2 and above + defaultVersion = 0 // unassigned users see v0 (disabled) +); + + +// --- Setup phase 4: Assign users to versions --- + +// Jsmith is a beta tester (v1) +SetUserAppVersion( + app = "myApp", + flagId = "550e8400-e29b-41d4-a716-446655440000", + users = ["jsmith"], + version = 1 +); + +// Everyone else gets v2 (released) +SetUserAppVersion( + app = "myApp", + flagId = "550e8400-e29b-41d4-a716-446655440000", + users = ["user1", "user2", "user3"], + version = 2 +); + + +// --- Runtime: Check if feature is enabled for current user --- + +if ( CheckFeatureFlag(app="myApp", flagId="550e8400-e29b-41d4-a716-446655440000") ) { + // jsmith: v1 >= 2? No → feature OFF + // user1: v2 >= 2? Yes → feature ON + // Render new dashboard +} else { + // Render legacy dashboard +} + + +// --- Later: Update a bucket description --- + +UpdateAppVersionBucket( + app = "myApp", + flagId = "550e8400-e29b-41d4-a716-446655440000", + version = 1, + description = "Early access (closed beta)" +); + + +// --- Cleanup: Delete unused bucket --- + +DeleteAppVersionBucket( + app = "myApp", + flagId = "550e8400-e29b-41d4-a716-446655440000", + version = 1 +); +// Users in v1 still exist; just the definition is gone + + +// --- Or: Remove a user from the flag entirely --- + +RemoveUserFromFeatureFlag( + app = "myApp", + flagId = "550e8400-e29b-41d4-a716-446655440000", + user = "jsmith" +); +// jsmith's assignment is deleted; they now use the flag's defaultVersion +``` + +--- + +## Permissions + +| Action | Who can do it | +|---|---| +| Create / update / delete flags | App owner or SEMOSS admin | +| Assign user versions | App owner or SEMOSS admin | +| Evaluate a flag (`CheckFeatureFlag`) | Any authenticated user with app access | + +Enforced via existing `SecurityProjectUtils` — no new permission infrastructure needed. + +--- + +## What's Reused from SEMOSS + +| Need | Reuse | +|---|---| +| DB persistence | Security DB (H2/RDB) via existing `AbstractSecurityUtils` patterns | +| Auth context | `User` object already on thread via `AccessToken` | +| Reactor pattern | Same `AbstractReactor` all operations use | +| Permission checks | `SecurityProjectUtils` for app owner validation | +| Group membership | Existing security group tables | + +--- + +## Implementation Status + +**Phase 1 — Core Version-Based Bucketing (✅ Complete)** +- [x] Data model: APP_FEATURE_FLAG, APP_VERSION_BUCKET, APP_USER_VERSION (all flagId-scoped) +- [x] Flag management: Create, Update, Delete +- [x] Version bucket management: Create, Update, Delete +- [x] User assignment: SetUserAppVersion, GetUserAppVersion +- [x] Evaluation: CheckFeatureFlag, GetUserFeatureFlags +- [x] Per-flag data isolation via flagId + +**Removed — Rule-Based System** +- ❌ APP_FEATURE_FLAG_RULE table (never queried in production; replaced by explicit version assignment) +- ❌ USER and GROUP rule types (replaced by direct SetUserAppVersion calls) + +**Not Planned** +- Percentage-based rollout (can be simulated with explicit version assignment) +- Time-window activation (can be managed externally) diff --git a/src/prerna/auth/utils/AbstractSecurityUtils.java b/src/prerna/auth/utils/AbstractSecurityUtils.java index b25869e92d0..a074e2291e2 100644 --- a/src/prerna/auth/utils/AbstractSecurityUtils.java +++ b/src/prerna/auth/utils/AbstractSecurityUtils.java @@ -2414,6 +2414,89 @@ public static void initialize() throws Exception { } } + // APP_FEATURE_FLAG + colNames = new String[] { "FLAG_ID", "APP_ID", "FLAG_KEY", "DESCRIPTION", "MIN_VERSION", "DEFAULT_VERSION", + "CREATED_BY", "CREATED_AT" }; + types = new String[] { "VARCHAR(255)", "VARCHAR(255)", "VARCHAR(255)", "VARCHAR(2000)", + INTEGER_DATATYPE_NAME, INTEGER_DATATYPE_NAME, "VARCHAR(255)", TIMESTAMP_DATATYPE_NAME }; + if (allowIfExistsTable) { + String sql = queryUtil.createTableIfNotExists("APP_FEATURE_FLAG", colNames, types); + classLogger.info("Running sql {}", sql); + securityDb.insertData(sql); + } else { + if (!queryUtil.tableExists(conn, "APP_FEATURE_FLAG", database, schema)) { + String sql = queryUtil.createTable("APP_FEATURE_FLAG", colNames, types); + classLogger.info("Running sql {}", sql); + securityDb.insertData(sql); + } + } + { + List allCols = queryUtil.getTableColumns(conn, "APP_FEATURE_FLAG", database, schema); + for (int i = 0; i < colNames.length; i++) { + String col = colNames[i]; + if (!allCols.contains(col) && !allCols.contains(col.toLowerCase())) { + classLogger.info("Column '{}' is not present in current list of columns: {}", col, allCols); + String addColumnSql = queryUtil.alterTableAddColumn("APP_FEATURE_FLAG", col, types[i]); + classLogger.info("Running sql {}", addColumnSql); + securityDb.insertData(addColumnSql); + } + } + } + + // APP_USER_VERSION + colNames = new String[] { "APP_ID", "FLAG_ID", "USER_ID", "VERSION" }; + types = new String[] { "VARCHAR(255)", "VARCHAR(255)", "VARCHAR(255)", INTEGER_DATATYPE_NAME }; + if (allowIfExistsTable) { + String sql = queryUtil.createTableIfNotExists("APP_USER_VERSION", colNames, types); + classLogger.info("Running sql {}", sql); + securityDb.insertData(sql); + } else { + if (!queryUtil.tableExists(conn, "APP_USER_VERSION", database, schema)) { + String sql = queryUtil.createTable("APP_USER_VERSION", colNames, types); + classLogger.info("Running sql {}", sql); + securityDb.insertData(sql); + } + } + { + List allCols = queryUtil.getTableColumns(conn, "APP_USER_VERSION", database, schema); + for (int i = 0; i < colNames.length; i++) { + String col = colNames[i]; + if (!allCols.contains(col) && !allCols.contains(col.toLowerCase())) { + classLogger.info("Column '{}' is not present in current list of columns: {}", col, allCols); + String addColumnSql = queryUtil.alterTableAddColumn("APP_USER_VERSION", col, types[i]); + classLogger.info("Running sql {}", addColumnSql); + securityDb.insertData(addColumnSql); + } + } + } + + // APP_VERSION_BUCKET + colNames = new String[] { "APP_ID", "FLAG_ID", "VERSION", "DESCRIPTION" }; + types = new String[] { "VARCHAR(255)", "VARCHAR(255)", INTEGER_DATATYPE_NAME, "VARCHAR(2000)" }; + if (allowIfExistsTable) { + String sql = queryUtil.createTableIfNotExists("APP_VERSION_BUCKET", colNames, types); + classLogger.info("Running sql {}", sql); + securityDb.insertData(sql); + } else { + if (!queryUtil.tableExists(conn, "APP_VERSION_BUCKET", database, schema)) { + String sql = queryUtil.createTable("APP_VERSION_BUCKET", colNames, types); + classLogger.info("Running sql {}", sql); + securityDb.insertData(sql); + } + } + { + List allCols = queryUtil.getTableColumns(conn, "APP_VERSION_BUCKET", database, schema); + for (int i = 0; i < colNames.length; i++) { + String col = colNames[i]; + if (!allCols.contains(col) && !allCols.contains(col.toLowerCase())) { + classLogger.info("Column '{}' is not present in current list of columns: {}", col, allCols); + String addColumnSql = queryUtil.alterTableAddColumn("APP_VERSION_BUCKET", col, types[i]); + classLogger.info("Running sql {}", addColumnSql); + securityDb.insertData(addColumnSql); + } + } + } + if (!conn.getAutoCommit()) { conn.commit(); } diff --git a/src/prerna/auth/utils/AppFeatureFlagUtils.java b/src/prerna/auth/utils/AppFeatureFlagUtils.java new file mode 100644 index 00000000000..9dbfef9826a --- /dev/null +++ b/src/prerna/auth/utils/AppFeatureFlagUtils.java @@ -0,0 +1,657 @@ +/******************************************************************************* + * Copyright 2015 Defense Health Agency (DHA) + * + * If your use of this software does not include any GPLv2 components: + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * ---------------------------------------------------------------------------- + * If your use of this software includes any GPLv2 components: + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + *******************************************************************************/ +package prerna.auth.utils; + +import java.sql.PreparedStatement; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +import prerna.auth.AccessToken; +import prerna.auth.AuthProvider; +import prerna.auth.User; +import prerna.engine.api.IRDBMSEngine; +import prerna.engine.api.IRawSelectWrapper; +import prerna.query.querystruct.SelectQueryStruct; +import prerna.query.querystruct.filters.SimpleQueryFilter; +import prerna.query.querystruct.selectors.QueryColumnSelector; +import prerna.rdf.engine.wrappers.WrapperManager; +import prerna.util.ConnectionUtils; +import prerna.util.SystemEngineRegistry; +import prerna.util.Utility; + +// Evaluation model: a flag has a MIN_VERSION; a user is enabled if their assigned version >= MIN_VERSION. +// Version 0 means everyone is enabled (no restriction). + +public class AppFeatureFlagUtils { + + private static final Logger classLogger = LogManager.getLogger(AppFeatureFlagUtils.class); + + public static final String PLATFORM_APP_ID = "SEMOSS"; + + private AppFeatureFlagUtils() { + } + + /** + * Returns true if the user is authorized to manage flags for the given appId. + * Platform flags (appId == PLATFORM_APP_ID) require admin; app flags require + * owner or admin. + */ + public static boolean canManageFlags(User user, String appId) { + if (PLATFORM_APP_ID.equals(appId)) { + return SecurityAdminUtils.userIsAdmin(user); + } + return SecurityProjectUtils.userIsOwner(user, appId) || SecurityAdminUtils.userIsAdmin(user); + } + + /** + * Returns true if the user can evaluate flags for the given appId. + * Platform flags are accessible to any authenticated user; app flags require + * view access. + */ + public static boolean canEvaluateFlags(User user, String appId) { + if (PLATFORM_APP_ID.equals(appId)) { + return user != null; + } + return SecurityProjectUtils.userCanViewProject(user, appId); + } + + // ------------------------------------------------------------------------- + // Evaluation + // ------------------------------------------------------------------------- + + public static boolean evaluate(String appId, String flagId, User user) { + IRDBMSEngine securityDb = SystemEngineRegistry.getSecurityDb(); + + // 1. Resolve MIN_VERSION and DEFAULT_VERSION from the flag + int minVersion = -1; + int defaultVersion = 0; + SelectQueryStruct qs = new SelectQueryStruct(); + qs.addSelector(new QueryColumnSelector("APP_FEATURE_FLAG__MIN_VERSION")); + qs.addSelector(new QueryColumnSelector("APP_FEATURE_FLAG__DEFAULT_VERSION")); + qs.addExplicitFilter(SimpleQueryFilter.makeColToValFilter("APP_FEATURE_FLAG__APP_ID", "==", appId)); + qs.addExplicitFilter(SimpleQueryFilter.makeColToValFilter("APP_FEATURE_FLAG__FLAG_ID", "==", flagId)); + try (IRawSelectWrapper wrapper = WrapperManager.getInstance().getRawWrapper(securityDb, qs)) { + if (wrapper.hasNext()) { + Object[] row = wrapper.next().getValues(); + minVersion = row[0] != null ? ((Number) row[0]).intValue() : 0; + defaultVersion = row[1] != null ? ((Number) row[1]).intValue() : 0; + } + } catch (Exception e) { + classLogger.error("Error resolving flag {}/{}", appId, flagId, e); + return false; + } + if (minVersion < 0) { + // flag not found + return false; + } + + // 2. Get user's assigned version; fall back to flag's DEFAULT_VERSION if + // unassigned + String userId = getUserId(user); + int userVersion = getUserVersion(appId, flagId, userId); + int effectiveVersion = userVersion >= 0 ? userVersion : defaultVersion; + + return effectiveVersion >= minVersion; + } + + // ------------------------------------------------------------------------- + // CRUD operations + // ------------------------------------------------------------------------- + + public static String createFlag(String appId, String flagKey, String description, String createdBy) { + IRDBMSEngine securityDb = SystemEngineRegistry.getSecurityDb(); + String flagId = UUID.randomUUID().toString(); + String query = "INSERT INTO APP_FEATURE_FLAG (FLAG_ID, APP_ID, FLAG_KEY, DESCRIPTION, MIN_VERSION, DEFAULT_VERSION, CREATED_BY, CREATED_AT) VALUES (?,?,?,?,?,?,?,?)"; + PreparedStatement ps = null; + try { + ps = securityDb.getPreparedStatement(query); + int idx = 1; + ps.setString(idx++, flagId); + ps.setString(idx++, appId); + ps.setString(idx++, flagKey); + ps.setString(idx++, description); + ps.setInt(idx++, 1); // minVersion: off by default until users are placed in v1+ + ps.setInt(idx++, 0); // defaultVersion: unassigned users are off + ps.setString(idx++, createdBy); + ps.setTimestamp(idx++, Utility.getCurrentSqlTimestampUTC()); + ps.execute(); + if (!ps.getConnection().getAutoCommit()) { + ps.getConnection().commit(); + } + } catch (Exception e) { + classLogger.error("Error creating flag {}/{}", appId, flagKey, e); + throw new IllegalArgumentException("Failed to create feature flag: " + e.getMessage()); + } finally { + ConnectionUtils.closeAllConnectionsIfPooling(securityDb, ps); + } + + // Automatically create v0 bucket for the new flag + createVersionBucketRecord(appId, flagId, 0, "Default version - feature disabled for most users"); + + return flagId; + } + + public static void updateFlag(String appId, String flagId, Integer minVersion, Integer defaultVersion) { + if (minVersion == null && defaultVersion == null) { + throw new IllegalArgumentException("At least one of 'minVersion' or 'defaultVersion' is required"); + } + IRDBMSEngine securityDb = SystemEngineRegistry.getSecurityDb(); + StringBuilder sql = new StringBuilder("UPDATE APP_FEATURE_FLAG SET "); + List params = new ArrayList<>(); + if (minVersion != null) { + sql.append("MIN_VERSION=?"); + params.add(minVersion); + } + if (defaultVersion != null) { + if (!params.isEmpty()) + sql.append(", "); + sql.append("DEFAULT_VERSION=?"); + params.add(defaultVersion); + } + sql.append(" WHERE APP_ID=? AND FLAG_ID=?"); + params.add(appId); + params.add(flagId); + + PreparedStatement ps = null; + try { + ps = securityDb.getPreparedStatement(sql.toString()); + for (int i = 0; i < params.size(); i++) { + Object p = params.get(i); + if (p instanceof Integer) { + ps.setInt(i + 1, (Integer) p); + } else { + ps.setString(i + 1, (String) p); + } + } + int updated = ps.executeUpdate(); + if (!ps.getConnection().getAutoCommit()) { + ps.getConnection().commit(); + } + if (updated == 0) { + throw new IllegalArgumentException("Flag not found: " + appId + "/" + flagId); + } + } catch (IllegalArgumentException e) { + throw e; + } catch (Exception e) { + classLogger.error("Error updating flag {}/{}", appId, flagId, e); + throw new IllegalArgumentException("Failed to update feature flag: " + e.getMessage()); + } finally { + ConnectionUtils.closeAllConnectionsIfPooling(securityDb, ps); + } + } + + public static void deleteFlag(String appId, String flagId) { + IRDBMSEngine securityDb = SystemEngineRegistry.getSecurityDb(); + PreparedStatement ps = null; + try { + ps = securityDb.getPreparedStatement("DELETE FROM APP_FEATURE_FLAG WHERE APP_ID=? AND FLAG_ID=?"); + ps.setString(1, appId); + ps.setString(2, flagId); + ps.execute(); + if (!ps.getConnection().getAutoCommit()) { + ps.getConnection().commit(); + } + } catch (Exception e) { + classLogger.error("Error deleting flag {}/{}", appId, flagId, e); + throw new IllegalArgumentException("Failed to delete feature flag: " + e.getMessage()); + } finally { + ConnectionUtils.closeAllConnectionsIfPooling(securityDb, ps); + } + // Clean up associated version buckets and user version assignments + try { + ps = securityDb.getPreparedStatement("DELETE FROM APP_VERSION_BUCKET WHERE APP_ID=? AND FLAG_ID=?"); + ps.setString(1, appId); + ps.setString(2, flagId); + ps.execute(); + if (!ps.getConnection().getAutoCommit()) { + ps.getConnection().commit(); + } + } catch (Exception e) { + classLogger.error("Error deleting version buckets for flag {}/{}", appId, flagId, e); + } finally { + ConnectionUtils.closeAllConnectionsIfPooling(securityDb, ps); + } + try { + ps = securityDb.getPreparedStatement("DELETE FROM APP_USER_VERSION WHERE APP_ID=? AND FLAG_ID=?"); + ps.setString(1, appId); + ps.setString(2, flagId); + ps.execute(); + if (!ps.getConnection().getAutoCommit()) { + ps.getConnection().commit(); + } + } catch (Exception e) { + classLogger.error("Error deleting user versions for flag {}/{}", appId, flagId, e); + } finally { + ConnectionUtils.closeAllConnectionsIfPooling(securityDb, ps); + } + } + + public static List> getFlagsForApp(String appId) { + IRDBMSEngine securityDb = SystemEngineRegistry.getSecurityDb(); + List> results = new ArrayList<>(); + + SelectQueryStruct qs = new SelectQueryStruct(); + qs.addSelector(new QueryColumnSelector("APP_FEATURE_FLAG__FLAG_ID")); + qs.addSelector(new QueryColumnSelector("APP_FEATURE_FLAG__FLAG_KEY")); + qs.addSelector(new QueryColumnSelector("APP_FEATURE_FLAG__DESCRIPTION")); + qs.addSelector(new QueryColumnSelector("APP_FEATURE_FLAG__MIN_VERSION")); + qs.addSelector(new QueryColumnSelector("APP_FEATURE_FLAG__DEFAULT_VERSION")); + qs.addSelector(new QueryColumnSelector("APP_FEATURE_FLAG__CREATED_BY")); + qs.addSelector(new QueryColumnSelector("APP_FEATURE_FLAG__CREATED_AT")); + qs.addExplicitFilter(SimpleQueryFilter.makeColToValFilter("APP_FEATURE_FLAG__APP_ID", "==", appId)); + + try (IRawSelectWrapper wrapper = WrapperManager.getInstance().getRawWrapper(securityDb, qs)) { + while (wrapper.hasNext()) { + Object[] row = wrapper.next().getValues(); + Map flag = new HashMap<>(); + flag.put("flagId", row[0]); + flag.put("flagKey", row[1]); + flag.put("description", row[2]); + flag.put("minVersion", row[3] != null ? ((Number) row[3]).intValue() : 0); + flag.put("defaultVersion", row[4] != null ? ((Number) row[4]).intValue() : 0); + flag.put("createdBy", row[5]); + flag.put("createdAt", row[6] != null ? row[6].toString() : null); + results.add(flag); + } + } catch (Exception e) { + classLogger.error("Error fetching flags for app {}", appId, e); + } + return results; + } + + // ------------------------------------------------------------------------- + // User version management + // ------------------------------------------------------------------------- + + public static void setUserVersions(String appId, String flagId, List userIds, int version) { + for (String userId : userIds) { + setUserVersion(appId, flagId, userId, version); + } + } + + public static void setUserVersion(String appId, String flagId, String userId, int version) { + IRDBMSEngine securityDb = SystemEngineRegistry.getSecurityDb(); + // Try update first, then insert + int updated = 0; + PreparedStatement ps = null; + try { + ps = securityDb.getPreparedStatement( + "UPDATE APP_USER_VERSION SET VERSION=? WHERE APP_ID=? AND FLAG_ID=? AND USER_ID=?"); + ps.setInt(1, version); + ps.setString(2, appId); + ps.setString(3, flagId); + ps.setString(4, userId); + updated = ps.executeUpdate(); + if (!ps.getConnection().getAutoCommit()) { + ps.getConnection().commit(); + } + } catch (Exception e) { + classLogger.error("Error updating user version", e); + } finally { + ConnectionUtils.closeAllConnectionsIfPooling(securityDb, ps); + } + if (updated == 0) { + try { + ps = securityDb + .getPreparedStatement( + "INSERT INTO APP_USER_VERSION (APP_ID, FLAG_ID, USER_ID, VERSION) VALUES (?,?,?,?)"); + ps.setString(1, appId); + ps.setString(2, flagId); + ps.setString(3, userId); + ps.setInt(4, version); + ps.execute(); + if (!ps.getConnection().getAutoCommit()) { + ps.getConnection().commit(); + } + } catch (Exception e) { + classLogger.error("Error inserting user version", e); + throw new IllegalArgumentException("Failed to set user version: " + e.getMessage()); + } finally { + ConnectionUtils.closeAllConnectionsIfPooling(securityDb, ps); + } + } + } + + public static int getUserVersion(String appId, String flagId, String userId) { + IRDBMSEngine securityDb = SystemEngineRegistry.getSecurityDb(); + SelectQueryStruct qs = new SelectQueryStruct(); + qs.addSelector(new QueryColumnSelector("APP_USER_VERSION__VERSION")); + qs.addExplicitFilter(SimpleQueryFilter.makeColToValFilter("APP_USER_VERSION__APP_ID", "==", appId)); + qs.addExplicitFilter(SimpleQueryFilter.makeColToValFilter("APP_USER_VERSION__FLAG_ID", "==", flagId)); + qs.addExplicitFilter(SimpleQueryFilter.makeColToValFilter("APP_USER_VERSION__USER_ID", "==", userId)); + try (IRawSelectWrapper wrapper = WrapperManager.getInstance().getRawWrapper(securityDb, qs)) { + if (wrapper.hasNext()) { + Object val = wrapper.next().getValues()[0]; + if (val != null) { + return ((Number) val).intValue(); + } + } + } catch (Exception e) { + classLogger.error("Error getting user version for {}/{}", appId, userId, e); + } + return -1; + } + + // ------------------------------------------------------------------------- + // Bulk evaluation for client-side handling + // ------------------------------------------------------------------------- + + public static Map getUserFeatureFlags(String appId, User user) { + IRDBMSEngine securityDb = SystemEngineRegistry.getSecurityDb(); + Map result = new HashMap<>(); + + SelectQueryStruct qs = new SelectQueryStruct(); + qs.addSelector(new QueryColumnSelector("APP_FEATURE_FLAG__FLAG_ID")); + qs.addSelector(new QueryColumnSelector("APP_FEATURE_FLAG__FLAG_KEY")); + qs.addExplicitFilter(SimpleQueryFilter.makeColToValFilter("APP_FEATURE_FLAG__APP_ID", "==", appId)); + + try (IRawSelectWrapper wrapper = WrapperManager.getInstance().getRawWrapper(securityDb, qs)) { + while (wrapper.hasNext()) { + Object[] row = wrapper.next().getValues(); + String flagId = (String) row[0]; + String flagKey = (String) row[1]; + result.put(flagKey, evaluate(appId, flagId, user)); + } + } catch (Exception e) { + classLogger.error("Error fetching flags for bulk evaluation, app={}", appId, e); + } + return result; + } + + public static Map> getVersionBucketsWithDetails(String appId, String flagId) { + IRDBMSEngine securityDb = SystemEngineRegistry.getSecurityDb(); + Map> buckets = new HashMap<>(); + + // First load all defined buckets and their descriptions from APP_VERSION_BUCKET + SelectQueryStruct bucketQs = new SelectQueryStruct(); + bucketQs.addSelector(new QueryColumnSelector("APP_VERSION_BUCKET__VERSION")); + bucketQs.addSelector(new QueryColumnSelector("APP_VERSION_BUCKET__DESCRIPTION")); + bucketQs.addExplicitFilter(SimpleQueryFilter.makeColToValFilter("APP_VERSION_BUCKET__APP_ID", "==", appId)); + bucketQs.addExplicitFilter(SimpleQueryFilter.makeColToValFilter("APP_VERSION_BUCKET__FLAG_ID", "==", flagId)); + try (IRawSelectWrapper wrapper = WrapperManager.getInstance().getRawWrapper(securityDb, bucketQs)) { + while (wrapper.hasNext()) { + Object[] row = wrapper.next().getValues(); + int version = row[0] != null ? ((Number) row[0]).intValue() : 0; + String description = row[1] != null ? (String) row[1] : ""; + Map bucket = new HashMap<>(); + bucket.put("version", version); + bucket.put("users", new ArrayList()); + bucket.put("description", description); + buckets.put(version, bucket); + } + } catch (Exception e) { + classLogger.error("Error fetching version bucket definitions for app {}/{}", appId, flagId, e); + } + + // Then populate each bucket's users from APP_USER_VERSION + SelectQueryStruct userQs = new SelectQueryStruct(); + userQs.addSelector(new QueryColumnSelector("APP_USER_VERSION__VERSION")); + userQs.addSelector(new QueryColumnSelector("APP_USER_VERSION__USER_ID")); + userQs.addExplicitFilter(SimpleQueryFilter.makeColToValFilter("APP_USER_VERSION__APP_ID", "==", appId)); + userQs.addExplicitFilter(SimpleQueryFilter.makeColToValFilter("APP_USER_VERSION__FLAG_ID", "==", flagId)); + try (IRawSelectWrapper wrapper = WrapperManager.getInstance().getRawWrapper(securityDb, userQs)) { + while (wrapper.hasNext()) { + Object[] row = wrapper.next().getValues(); + int version = row[0] != null ? ((Number) row[0]).intValue() : 0; + String userId = (String) row[1]; + if (userId == null) { + continue; + } + // Only populate users into declared buckets for this flag + if (buckets.containsKey(version)) { + @SuppressWarnings("unchecked") + List users = (List) buckets.get(version).get("users"); + users.add(userId); + } + } + } catch (Exception e) { + classLogger.error("Error fetching version bucket users for app {}", appId, e); + } + return buckets; + } + + /** + * Creates a version bucket for a feature flag. Validates the flag exists. + * This allows the version to appear in UI even before users are assigned to it. + */ + public static void createVersionBucket(String appId, String flagId, int version, String description) { + if (!flagExists(appId, flagId)) { + throw new IllegalArgumentException("Feature flag not found: " + appId + "/" + flagId); + } + if (versionBucketExists(appId, flagId, version)) { + throw new IllegalArgumentException( + "Version bucket already exists: app=" + appId + ", flag=" + flagId + ", version=" + version); + } + createVersionBucketRecord(appId, flagId, version, description); + } + + /** + * Inserts a row into APP_VERSION_BUCKET without any flag validation. Used + * internally. + */ + private static void createVersionBucketRecord(String appId, String flagId, int version, String description) { + IRDBMSEngine securityDb = SystemEngineRegistry.getSecurityDb(); + PreparedStatement ps = null; + try { + ps = securityDb.getPreparedStatement( + "INSERT INTO APP_VERSION_BUCKET (APP_ID, FLAG_ID, VERSION, DESCRIPTION) VALUES (?,?,?,?)"); + ps.setString(1, appId); + ps.setString(2, flagId); + ps.setInt(3, version); + ps.setString(4, description != null ? description : ""); + ps.execute(); + if (!ps.getConnection().getAutoCommit()) { + ps.getConnection().commit(); + } + } catch (Exception e) { + classLogger.error("Error creating version bucket record {}/{}", appId, version, e); + throw new IllegalArgumentException("Failed to create version bucket: " + e.getMessage()); + } finally { + ConnectionUtils.closeAllConnectionsIfPooling(securityDb, ps); + } + } + + /** + * Updates only the description for an existing version bucket. + */ + public static void updateVersionBucketDescription(String appId, String flagId, int version, String description) { + IRDBMSEngine securityDb = SystemEngineRegistry.getSecurityDb(); + PreparedStatement ps = null; + try { + ps = securityDb.getPreparedStatement( + "UPDATE APP_VERSION_BUCKET SET DESCRIPTION=? WHERE APP_ID=? AND FLAG_ID=? AND VERSION=?"); + ps.setString(1, description != null ? description : ""); + ps.setString(2, appId); + ps.setString(3, flagId); + ps.setInt(4, version); + int updated = ps.executeUpdate(); + if (!ps.getConnection().getAutoCommit()) { + ps.getConnection().commit(); + } + if (updated == 0) { + throw new IllegalArgumentException( + "Version bucket not found: app=" + appId + ", flag=" + flagId + ", version=" + version); + } + } catch (IllegalArgumentException e) { + throw e; + } catch (Exception e) { + classLogger.error("Error updating version bucket description {}/{}", appId, flagId, e); + throw new IllegalArgumentException("Failed to update version bucket description: " + e.getMessage()); + } finally { + ConnectionUtils.closeAllConnectionsIfPooling(securityDb, ps); + } + } + + /** + * Deletes a version bucket definition. Does not affect user assignments. + */ + public static void deleteVersionBucket(String appId, String flagId, int version) { + IRDBMSEngine securityDb = SystemEngineRegistry.getSecurityDb(); + PreparedStatement ps = null; + try { + ps = securityDb.getPreparedStatement( + "DELETE FROM APP_VERSION_BUCKET WHERE APP_ID=? AND FLAG_ID=? AND VERSION=?"); + ps.setString(1, appId); + ps.setString(2, flagId); + ps.setInt(3, version); + int deleted = ps.executeUpdate(); + if (!ps.getConnection().getAutoCommit()) { + ps.getConnection().commit(); + } + if (deleted == 0) { + throw new IllegalArgumentException( + "Version bucket not found: app=" + appId + ", flag=" + flagId + ", version=" + version); + } + } catch (IllegalArgumentException e) { + throw e; + } catch (Exception e) { + classLogger.error("Error deleting version bucket {}/{}", appId, flagId, e); + throw new IllegalArgumentException("Failed to delete version bucket: " + e.getMessage()); + } finally { + ConnectionUtils.closeAllConnectionsIfPooling(securityDb, ps); + } + } + + /** + * Removes a user from a flag (deletes the assignment row). User falls back to + * flag's defaultVersion. + */ + public static void removeUserFromFlag(String appId, String flagId, String userId) { + IRDBMSEngine securityDb = SystemEngineRegistry.getSecurityDb(); + PreparedStatement ps = null; + try { + ps = securityDb.getPreparedStatement( + "DELETE FROM APP_USER_VERSION WHERE APP_ID=? AND FLAG_ID=? AND USER_ID=?"); + ps.setString(1, appId); + ps.setString(2, flagId); + ps.setString(3, userId); + ps.executeUpdate(); + if (!ps.getConnection().getAutoCommit()) { + ps.getConnection().commit(); + } + } catch (Exception e) { + classLogger.error("Error removing user {} from flag {}/{}", userId, appId, flagId, e); + throw new IllegalArgumentException("Failed to remove user from flag: " + e.getMessage()); + } finally { + ConnectionUtils.closeAllConnectionsIfPooling(securityDb, ps); + } + } + + /** + * Returns all version numbers assigned for an app (including empty buckets). + */ + public static List getVersionsForFlag(String appId, String flagId) { + IRDBMSEngine securityDb = SystemEngineRegistry.getSecurityDb(); + List versions = new ArrayList<>(); + SelectQueryStruct qs = new SelectQueryStruct(); + qs.addSelector(new QueryColumnSelector("APP_USER_VERSION__VERSION")); + qs.addExplicitFilter(SimpleQueryFilter.makeColToValFilter("APP_USER_VERSION__APP_ID", "==", appId)); + qs.addExplicitFilter(SimpleQueryFilter.makeColToValFilter("APP_USER_VERSION__FLAG_ID", "==", flagId)); + try (IRawSelectWrapper wrapper = WrapperManager.getInstance().getRawWrapper(securityDb, qs)) { + while (wrapper.hasNext()) { + Object val = wrapper.next().getValues()[0]; + if (val != null) { + int version = ((Number) val).intValue(); + if (!versions.contains(version)) { + versions.add(version); + } + } + } + } catch (Exception e) { + classLogger.error("Error fetching versions for app {}/{}", appId, flagId, e); + } + return versions; + } + + /** + * Checks if a feature flag exists for the app. + */ + public static boolean flagExists(String appId, String flagId) { + IRDBMSEngine securityDb = SystemEngineRegistry.getSecurityDb(); + SelectQueryStruct qs = new SelectQueryStruct(); + qs.addSelector(new QueryColumnSelector("APP_FEATURE_FLAG__FLAG_ID")); + qs.addExplicitFilter(SimpleQueryFilter.makeColToValFilter("APP_FEATURE_FLAG__APP_ID", "==", appId)); + qs.addExplicitFilter(SimpleQueryFilter.makeColToValFilter("APP_FEATURE_FLAG__FLAG_ID", "==", flagId)); + try (IRawSelectWrapper wrapper = WrapperManager.getInstance().getRawWrapper(securityDb, qs)) { + return wrapper.hasNext(); + } catch (Exception e) { + classLogger.error("Error checking flag existence {}/{}", appId, flagId, e); + return false; + } + } + + /** + * Checks if a version bucket already exists for an app and flag. + */ + public static boolean versionBucketExists(String appId, String flagId, int version) { + IRDBMSEngine securityDb = SystemEngineRegistry.getSecurityDb(); + SelectQueryStruct qs = new SelectQueryStruct(); + qs.addSelector(new QueryColumnSelector("APP_VERSION_BUCKET__VERSION")); + qs.addExplicitFilter(SimpleQueryFilter.makeColToValFilter("APP_VERSION_BUCKET__APP_ID", "==", appId)); + qs.addExplicitFilter(SimpleQueryFilter.makeColToValFilter("APP_VERSION_BUCKET__FLAG_ID", "==", flagId)); + qs.addExplicitFilter(SimpleQueryFilter.makeColToValFilter("APP_VERSION_BUCKET__VERSION", "==", version + "")); + try (IRawSelectWrapper wrapper = WrapperManager.getInstance().getRawWrapper(securityDb, qs)) { + return wrapper.hasNext(); + } catch (Exception e) { + classLogger.error("Error checking version bucket existence {}/{}", appId, version, e); + return false; + } + } + + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + public static String getUserId(User user) { + if (user == null) { + return null; + } + AuthProvider primary = user.getPrimaryLogin(); + if (primary != null) { + AccessToken token = user.getAccessToken(primary); + if (token != null) { + return token.getId(); + } + } + List logins = user.getLogins(); + if (logins != null && !logins.isEmpty()) { + AccessToken token = user.getAccessToken(logins.get(0)); + if (token != null) { + return token.getId(); + } + } + return null; + } + +} diff --git a/src/prerna/auth/utils/SecurityOwlCreator.java b/src/prerna/auth/utils/SecurityOwlCreator.java index 971ddc25714..845ae472b24 100644 --- a/src/prerna/auth/utils/SecurityOwlCreator.java +++ b/src/prerna/auth/utils/SecurityOwlCreator.java @@ -88,6 +88,11 @@ public class SecurityOwlCreator { // github app integration conceptsRequired.add("GITHUB_APP"); conceptsRequired.add("GITHUB_PROJECT_LINK"); + + // feature gate + conceptsRequired.add("APP_FEATURE_FLAG"); + conceptsRequired.add("APP_VERSION_BUCKET"); + conceptsRequired.add("APP_USER_VERSION"); } private static List relationshipsRequired = new ArrayList(); @@ -646,6 +651,31 @@ private void writeNewOwl(WriteOWLEngine owler) throws Exception { owler.addRelation("GITHUB_APP", "GITHUB_PROJECT_LINK", "GITHUB_APP.APP_ID.GITHUB_PROJECT_LINK.APP_ID"); owler.addRelation("PROJECT", "GITHUB_PROJECT_LINK", "PROJECT.PROJECTID.GITHUB_PROJECT_LINK.PROJECT_ID"); + // APP_FEATURE_FLAG + owler.addConcept("APP_FEATURE_FLAG", null, null); + owler.addProp("APP_FEATURE_FLAG", "FLAG_ID", "VARCHAR(255)"); + owler.addProp("APP_FEATURE_FLAG", "APP_ID", "VARCHAR(255)"); + owler.addProp("APP_FEATURE_FLAG", "FLAG_KEY", "VARCHAR(255)"); + owler.addProp("APP_FEATURE_FLAG", "DESCRIPTION", "VARCHAR(2000)"); + owler.addProp("APP_FEATURE_FLAG", "MIN_VERSION", "INT"); + owler.addProp("APP_FEATURE_FLAG", "DEFAULT_VERSION", "INT"); + owler.addProp("APP_FEATURE_FLAG", "CREATED_BY", "VARCHAR(255)"); + owler.addProp("APP_FEATURE_FLAG", "CREATED_AT", "TIMESTAMP"); + + // APP_USER_VERSION + owler.addConcept("APP_USER_VERSION", null, null); + owler.addProp("APP_USER_VERSION", "APP_ID", "VARCHAR(255)"); + owler.addProp("APP_USER_VERSION", "FLAG_ID", "VARCHAR(255)"); + owler.addProp("APP_USER_VERSION", "USER_ID", "VARCHAR(255)"); + owler.addProp("APP_USER_VERSION", "VERSION", "INT"); + + // APP_VERSION_BUCKET + owler.addConcept("APP_VERSION_BUCKET", null, null); + owler.addProp("APP_VERSION_BUCKET", "APP_ID", "VARCHAR(255)"); + owler.addProp("APP_VERSION_BUCKET", "FLAG_ID", "VARCHAR(255)"); + owler.addProp("APP_VERSION_BUCKET", "VERSION", "INT"); + owler.addProp("APP_VERSION_BUCKET", "DESCRIPTION", "VARCHAR(2000)"); + owler.commit(); owler.export(); } diff --git a/src/prerna/reactor/featuregate/CheckFeatureFlagReactor.java b/src/prerna/reactor/featuregate/CheckFeatureFlagReactor.java new file mode 100644 index 00000000000..a4c3cb9da4a --- /dev/null +++ b/src/prerna/reactor/featuregate/CheckFeatureFlagReactor.java @@ -0,0 +1,75 @@ +/******************************************************************************* + * Copyright 2015 Defense Health Agency (DHA) + * + * If your use of this software does not include any GPLv2 components: + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * ---------------------------------------------------------------------------- + * If your use of this software includes any GPLv2 components: + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + *******************************************************************************/ +package prerna.reactor.featuregate; + +import prerna.auth.User; +import prerna.auth.utils.AppFeatureFlagUtils; +import prerna.reactor.AbstractReactor; +import prerna.sablecc2.om.PixelDataType; +import prerna.sablecc2.om.nounmeta.NounMetadata; + +/** + * Evaluates a single feature flag for the current user and returns true or + * false. + * + * Pixel: CheckFeatureFlag(app="myApp", flagId="uuid") + * + * Any authenticated user with access to the app may call this. + */ +public class CheckFeatureFlagReactor extends AbstractReactor { + + private static final String APP_KEY = "app"; + private static final String FLAG_ID_PARAM = "flagId"; + + public CheckFeatureFlagReactor() { + this.keysToGet = new String[] { APP_KEY, FLAG_ID_PARAM }; + } + + @Override + public NounMetadata execute() { + organizeKeys(); + User user = this.insight.getUser(); + + String appId = this.keyValue.get(APP_KEY); + String flagId = this.keyValue.get(FLAG_ID_PARAM); + + if (appId == null || appId.isEmpty()) { + throw new IllegalArgumentException("'app' is required"); + } + if (flagId == null || flagId.isEmpty()) { + throw new IllegalArgumentException("'flagId' is required"); + } + + if (!AppFeatureFlagUtils.canEvaluateFlags(user, appId)) { + throw new IllegalArgumentException("User does not have access to this app"); + } + + boolean result = AppFeatureFlagUtils.evaluate(appId, flagId, user); + return new NounMetadata(result, PixelDataType.BOOLEAN); + } +} diff --git a/src/prerna/reactor/featuregate/CreateAppFeatureFlagReactor.java b/src/prerna/reactor/featuregate/CreateAppFeatureFlagReactor.java new file mode 100644 index 00000000000..f9982beb0ce --- /dev/null +++ b/src/prerna/reactor/featuregate/CreateAppFeatureFlagReactor.java @@ -0,0 +1,79 @@ +/******************************************************************************* + * Copyright 2015 Defense Health Agency (DHA) + * + * If your use of this software does not include any GPLv2 components: + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * ---------------------------------------------------------------------------- + * If your use of this software includes any GPLv2 components: + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + *******************************************************************************/ +package prerna.reactor.featuregate; + +import prerna.auth.User; +import prerna.auth.utils.AppFeatureFlagUtils; +import prerna.reactor.AbstractReactor; +import prerna.sablecc2.om.PixelDataType; +import prerna.sablecc2.om.PixelOperationType; +import prerna.sablecc2.om.nounmeta.NounMetadata; + +/** + * Creates a new feature flag for an app. + * + * Pixel: CreateAppFeatureFlag(app="myApp", key="new-dashboard", + * description="Redesigned UI", default=false) + * + * Requires app owner or admin. + */ +public class CreateAppFeatureFlagReactor extends AbstractReactor { + + private static final String APP_KEY = "app"; + private static final String FLAG_KEY_PARAM = "key"; + private static final String DESCRIPTION_KEY = "description"; + + public CreateAppFeatureFlagReactor() { + this.keysToGet = new String[] { APP_KEY, FLAG_KEY_PARAM, DESCRIPTION_KEY }; + } + + @Override + public NounMetadata execute() { + organizeKeys(); + User user = this.insight.getUser(); + + String appId = this.keyValue.get(APP_KEY); + String flagKey = this.keyValue.get(FLAG_KEY_PARAM); + String description = this.keyValue.get(DESCRIPTION_KEY); + + if (appId == null || appId.isEmpty()) { + throw new IllegalArgumentException("'app' is required"); + } + if (flagKey == null || flagKey.isEmpty()) { + throw new IllegalArgumentException("'key' is required"); + } + if (!AppFeatureFlagUtils.canManageFlags(user, appId)) { + throw new IllegalArgumentException("User does not have permission to manage feature flags for this app"); + } + + String createdBy = AppFeatureFlagUtils.getUserId(user); + String flagId = AppFeatureFlagUtils.createFlag(appId, flagKey, description, createdBy); + + return new NounMetadata(flagId, PixelDataType.CONST_STRING, PixelOperationType.OPERATION); + } +} diff --git a/src/prerna/reactor/featuregate/CreateAppVersionBucketReactor.java b/src/prerna/reactor/featuregate/CreateAppVersionBucketReactor.java new file mode 100644 index 00000000000..51f1d10af68 --- /dev/null +++ b/src/prerna/reactor/featuregate/CreateAppVersionBucketReactor.java @@ -0,0 +1,103 @@ +/******************************************************************************* + * Copyright 2015 Defense Health Agency (DHA) + * + * If your use of this software does not include any GPLv2 components: + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * ---------------------------------------------------------------------------- + * If your use of this software includes any GPLv2 components: + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + *******************************************************************************/ +package prerna.reactor.featuregate; + +import prerna.auth.User; +import prerna.auth.utils.AppFeatureFlagUtils; +import prerna.reactor.AbstractReactor; +import prerna.sablecc2.om.PixelDataType; +import prerna.sablecc2.om.PixelOperationType; +import prerna.sablecc2.om.nounmeta.NounMetadata; + +/** + * Creates an empty version bucket for a feature flag to enable UI rendering + * before users are assigned. + * + * Pixel: CreateAppVersionBucket(app="myApp", flagId="uuid", version=2, + * description="Beta testers") + * + * This allows you to pre-create versions for UI purposes. Later, you can assign + * users to these versions + * using SetUserAppVersion. The bucket will appear in GetAppVersionBuckets even + * before any users are assigned. + * + * Requires app owner or admin. + */ +public class CreateAppVersionBucketReactor extends AbstractReactor { + + private static final String APP_KEY = "app"; + private static final String FLAG_ID_PARAM = "flagId"; + private static final String VERSION_KEY = "version"; + + public CreateAppVersionBucketReactor() { + this.keysToGet = new String[] { APP_KEY, FLAG_ID_PARAM, VERSION_KEY }; + } + + @Override + public NounMetadata execute() { + organizeKeys(); + User user = this.insight.getUser(); + + String appId = this.keyValue.get(APP_KEY); + String flagId = this.keyValue.get(FLAG_ID_PARAM); + String versionStr = this.keyValue.get(VERSION_KEY); + String description = this.keyValue.get("description"); + if (description == null) { + description = ""; + } + + if (appId == null || appId.isEmpty()) { + throw new IllegalArgumentException("'app' is required"); + } + if (flagId == null || flagId.isEmpty()) { + throw new IllegalArgumentException("'flagId' is required"); + } + if (versionStr == null || versionStr.isEmpty()) { + throw new IllegalArgumentException("'version' is required"); + } + + int version; + try { + version = Integer.parseInt(versionStr); + } catch (NumberFormatException e) { + throw new IllegalArgumentException("'version' must be an integer"); + } + + if (version < 0) { + throw new IllegalArgumentException("'version' must be >= 0"); + } + + if (!AppFeatureFlagUtils.canManageFlags(user, appId)) { + throw new IllegalArgumentException("User does not have permission to manage feature flags for this app"); + } + + AppFeatureFlagUtils.createVersionBucket(appId, flagId, version, description); + + return new NounMetadata(true, PixelDataType.BOOLEAN, PixelOperationType.OPERATION); + } +} diff --git a/src/prerna/reactor/featuregate/DeleteAppFeatureFlagReactor.java b/src/prerna/reactor/featuregate/DeleteAppFeatureFlagReactor.java new file mode 100644 index 00000000000..304f3e38362 --- /dev/null +++ b/src/prerna/reactor/featuregate/DeleteAppFeatureFlagReactor.java @@ -0,0 +1,76 @@ +/******************************************************************************* + * Copyright 2015 Defense Health Agency (DHA) + * + * If your use of this software does not include any GPLv2 components: + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * ---------------------------------------------------------------------------- + * If your use of this software includes any GPLv2 components: + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + *******************************************************************************/ +package prerna.reactor.featuregate; + +import prerna.auth.User; +import prerna.auth.utils.AppFeatureFlagUtils; +import prerna.reactor.AbstractReactor; +import prerna.sablecc2.om.PixelDataType; +import prerna.sablecc2.om.PixelOperationType; +import prerna.sablecc2.om.nounmeta.NounMetadata; + +/** + * Removes a feature flag and all its rules from an app. + * + * Pixel: DeleteAppFeatureFlag(app="myApp", flagId="uuid") + * + * Requires app owner or admin. + */ +public class DeleteAppFeatureFlagReactor extends AbstractReactor { + + private static final String APP_KEY = "app"; + private static final String FLAG_ID_PARAM = "flagId"; + + public DeleteAppFeatureFlagReactor() { + this.keysToGet = new String[] { APP_KEY, FLAG_ID_PARAM }; + } + + @Override + public NounMetadata execute() { + organizeKeys(); + User user = this.insight.getUser(); + + String appId = this.keyValue.get(APP_KEY); + String flagId = this.keyValue.get(FLAG_ID_PARAM); + + if (appId == null || appId.isEmpty()) { + throw new IllegalArgumentException("'app' is required"); + } + if (flagId == null || flagId.isEmpty()) { + throw new IllegalArgumentException("'flagId' is required"); + } + + if (!AppFeatureFlagUtils.canManageFlags(user, appId)) { + throw new IllegalArgumentException("User does not have permission to manage feature flags for this app"); + } + + AppFeatureFlagUtils.deleteFlag(appId, flagId); + + return new NounMetadata(true, PixelDataType.BOOLEAN, PixelOperationType.OPERATION); + } +} diff --git a/src/prerna/reactor/featuregate/DeleteAppVersionBucketReactor.java b/src/prerna/reactor/featuregate/DeleteAppVersionBucketReactor.java new file mode 100644 index 00000000000..e0783d90d1b --- /dev/null +++ b/src/prerna/reactor/featuregate/DeleteAppVersionBucketReactor.java @@ -0,0 +1,88 @@ +/******************************************************************************* + * Copyright 2015 Defense Health Agency (DHA) + * + * If your use of this software does not include any GPLv2 components: + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * ---------------------------------------------------------------------------- + * If your use of this software includes any GPLv2 components: + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + *******************************************************************************/ +package prerna.reactor.featuregate; + +import prerna.auth.User; +import prerna.auth.utils.AppFeatureFlagUtils; +import prerna.reactor.AbstractReactor; +import prerna.sablecc2.om.PixelDataType; +import prerna.sablecc2.om.PixelOperationType; +import prerna.sablecc2.om.nounmeta.NounMetadata; + +/** + * Deletes a version bucket definition. User assignments are not affected. + * + * Pixel: DeleteAppVersionBucket(app="myApp", flagId="uuid", version=2) + * + * Requires app owner or admin. + */ +public class DeleteAppVersionBucketReactor extends AbstractReactor { + + private static final String APP_KEY = "app"; + private static final String FLAG_ID_PARAM = "flagId"; + private static final String VERSION_KEY = "version"; + + public DeleteAppVersionBucketReactor() { + this.keysToGet = new String[] { APP_KEY, FLAG_ID_PARAM, VERSION_KEY }; + } + + @Override + public NounMetadata execute() { + organizeKeys(); + User user = this.insight.getUser(); + + String appId = this.keyValue.get(APP_KEY); + String flagId = this.keyValue.get(FLAG_ID_PARAM); + String versionStr = this.keyValue.get(VERSION_KEY); + + if (appId == null || appId.isEmpty()) { + throw new IllegalArgumentException("'app' is required"); + } + if (flagId == null || flagId.isEmpty()) { + throw new IllegalArgumentException("'flagId' is required"); + } + if (versionStr == null || versionStr.isEmpty()) { + throw new IllegalArgumentException("'version' is required"); + } + + int version; + try { + version = Integer.parseInt(versionStr); + } catch (NumberFormatException e) { + throw new IllegalArgumentException("'version' must be an integer"); + } + + if (!AppFeatureFlagUtils.canManageFlags(user, appId)) { + throw new IllegalArgumentException("User does not have permission to manage feature flags for this app"); + } + + AppFeatureFlagUtils.deleteVersionBucket(appId, flagId, version); + + return new NounMetadata(true, PixelDataType.BOOLEAN, PixelOperationType.OPERATION); + } +} diff --git a/src/prerna/reactor/featuregate/GetAppFeatureFlagsReactor.java b/src/prerna/reactor/featuregate/GetAppFeatureFlagsReactor.java new file mode 100644 index 00000000000..c5e3c0a6819 --- /dev/null +++ b/src/prerna/reactor/featuregate/GetAppFeatureFlagsReactor.java @@ -0,0 +1,71 @@ +/******************************************************************************* + * Copyright 2015 Defense Health Agency (DHA) + * + * If your use of this software does not include any GPLv2 components: + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * ---------------------------------------------------------------------------- + * If your use of this software includes any GPLv2 components: + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + *******************************************************************************/ +package prerna.reactor.featuregate; + +import java.util.List; +import java.util.Map; + +import prerna.auth.User; +import prerna.auth.utils.AppFeatureFlagUtils; +import prerna.reactor.AbstractReactor; +import prerna.sablecc2.om.PixelDataType; +import prerna.sablecc2.om.nounmeta.NounMetadata; + +/** + * Returns all feature flags and their rules for an app. + * + * Pixel: GetAppFeatureFlags(app="myApp") + * + * Requires app owner or admin. + */ +public class GetAppFeatureFlagsReactor extends AbstractReactor { + + private static final String APP_KEY = "app"; + + public GetAppFeatureFlagsReactor() { + this.keysToGet = new String[] { APP_KEY }; + } + + @Override + public NounMetadata execute() { + organizeKeys(); + User user = this.insight.getUser(); + + String appId = this.keyValue.get(APP_KEY); + if (appId == null || appId.isEmpty()) { + throw new IllegalArgumentException("'app' is required"); + } + + if (!AppFeatureFlagUtils.canManageFlags(user, appId)) { + throw new IllegalArgumentException("User does not have permission to view feature flags for this app"); + } + + List> flags = AppFeatureFlagUtils.getFlagsForApp(appId); + return new NounMetadata(flags, PixelDataType.VECTOR); + } +} diff --git a/src/prerna/reactor/featuregate/GetAppVersionBucketsReactor.java b/src/prerna/reactor/featuregate/GetAppVersionBucketsReactor.java new file mode 100644 index 00000000000..cb97680ef9b --- /dev/null +++ b/src/prerna/reactor/featuregate/GetAppVersionBucketsReactor.java @@ -0,0 +1,99 @@ +/******************************************************************************* + * Copyright 2015 Defense Health Agency (DHA) + * + * If your use of this software does not include any GPLv2 components: + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * ---------------------------------------------------------------------------- + * If your use of this software includes any GPLv2 components: + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + *******************************************************************************/ +package prerna.reactor.featuregate; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.TreeMap; + +import prerna.auth.User; +import prerna.auth.utils.AppFeatureFlagUtils; +import prerna.reactor.AbstractReactor; +import prerna.sablecc2.om.PixelDataType; +import prerna.sablecc2.om.nounmeta.NounMetadata; + +/** + * Returns version buckets (users grouped by version) and flags with their + * minVersion thresholds for an app. + * + * Pixel: GetAppVersionBuckets(app="SEMOSS", flagId="uuid") + * + * Response shape: + * { + * "buckets": [ { "version": 0, "users": ["id1","id2"], "description": "..." }, + * ... ], + * "flags": [ { "flagKey": "new-ui", "minVersion": 1 }, ... ] + * } + * + * Requires app owner or admin. + */ +public class GetAppVersionBucketsReactor extends AbstractReactor { + + private static final String APP_KEY = "app"; + private static final String FLAG_ID_PARAM = "flagId"; + + public GetAppVersionBucketsReactor() { + this.keysToGet = new String[] { APP_KEY, FLAG_ID_PARAM }; + } + + @Override + public NounMetadata execute() { + organizeKeys(); + User user = this.insight.getUser(); + + String appId = this.keyValue.get(APP_KEY); + String flagId = this.keyValue.get(FLAG_ID_PARAM); + if (appId == null || appId.isEmpty()) { + throw new IllegalArgumentException("'app' is required"); + } + if (flagId == null || flagId.isEmpty()) { + throw new IllegalArgumentException("'flagId' is required"); + } + if (!AppFeatureFlagUtils.canManageFlags(user, appId)) { + throw new IllegalArgumentException("User does not have permission to manage feature flags for this app"); + } + + // Version buckets sorted ascending with descriptions, scoped to this flag + Map> rawBuckets = AppFeatureFlagUtils.getVersionBucketsWithDetails(appId, flagId); + List> buckets = new ArrayList<>(); + for (Map.Entry> entry : new TreeMap<>(rawBuckets).entrySet()) { + buckets.add(entry.getValue()); + } + + // Flags with their minVersion thresholds + List> flags = AppFeatureFlagUtils.getFlagsForApp(appId); + + Map result = new HashMap<>(); + result.put("buckets", buckets); + result.put("flags", flags); + + return new NounMetadata(result, PixelDataType.MAP); + } +} diff --git a/src/prerna/reactor/featuregate/GetUserAppVersionReactor.java b/src/prerna/reactor/featuregate/GetUserAppVersionReactor.java new file mode 100644 index 00000000000..092ef7acc70 --- /dev/null +++ b/src/prerna/reactor/featuregate/GetUserAppVersionReactor.java @@ -0,0 +1,84 @@ +/******************************************************************************* + * Copyright 2015 Defense Health Agency (DHA) + * + * If your use of this software does not include any GPLv2 components: + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * ---------------------------------------------------------------------------- + * If your use of this software includes any GPLv2 components: + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + *******************************************************************************/ +package prerna.reactor.featuregate; + +import prerna.auth.User; +import prerna.auth.utils.AppFeatureFlagUtils; +import prerna.reactor.AbstractReactor; +import prerna.sablecc2.om.PixelDataType; +import prerna.sablecc2.om.nounmeta.NounMetadata; + +/** + * Returns a user's assigned version within an app, or -1 if unset. + * + * Pixel: GetUserAppVersion(app="myApp", flagId="uuid", user="jsmith") + * + * Requires app owner or admin (to look up other users); any user may query + * themselves. + */ +public class GetUserAppVersionReactor extends AbstractReactor { + + private static final String APP_KEY = "app"; + private static final String FLAG_ID_PARAM = "flagId"; + private static final String USER_KEY = "user"; + + public GetUserAppVersionReactor() { + this.keysToGet = new String[] { APP_KEY, FLAG_ID_PARAM, USER_KEY }; + } + + @Override + public NounMetadata execute() { + organizeKeys(); + User user = this.insight.getUser(); + + String appId = this.keyValue.get(APP_KEY); + String flagId = this.keyValue.get(FLAG_ID_PARAM); + String targetUserId = this.keyValue.get(USER_KEY); + + if (appId == null || appId.isEmpty()) { + throw new IllegalArgumentException("'app' is required"); + } + if (flagId == null || flagId.isEmpty()) { + throw new IllegalArgumentException("'flagId' is required"); + } + + String requestingUserId = AppFeatureFlagUtils.getUserId(user); + + // If querying another user, require owner/admin + if (targetUserId != null && !targetUserId.isEmpty() && !targetUserId.equals(requestingUserId)) { + if (!AppFeatureFlagUtils.canManageFlags(user, appId)) { + throw new IllegalArgumentException("User does not have permission to view other users' versions"); + } + } else { + targetUserId = requestingUserId; + } + + int version = AppFeatureFlagUtils.getUserVersion(appId, flagId, targetUserId); + return new NounMetadata(version, PixelDataType.CONST_INT); + } +} diff --git a/src/prerna/reactor/featuregate/GetUserFeatureFlagsReactor.java b/src/prerna/reactor/featuregate/GetUserFeatureFlagsReactor.java new file mode 100644 index 00000000000..243de347122 --- /dev/null +++ b/src/prerna/reactor/featuregate/GetUserFeatureFlagsReactor.java @@ -0,0 +1,71 @@ +/******************************************************************************* + * Copyright 2015 Defense Health Agency (DHA) + * + * If your use of this software does not include any GPLv2 components: + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * ---------------------------------------------------------------------------- + * If your use of this software includes any GPLv2 components: + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + *******************************************************************************/ +package prerna.reactor.featuregate; + +import java.util.Map; + +import prerna.auth.User; +import prerna.auth.utils.AppFeatureFlagUtils; +import prerna.reactor.AbstractReactor; +import prerna.sablecc2.om.PixelDataType; +import prerna.sablecc2.om.nounmeta.NounMetadata; + +/** + * Returns all feature flag keys and their evaluated boolean state for the current user. + * The app can handle flag checks client-side using this response. + * + * Pixel: GetUserFeatureFlags(app="myApp") + * + * Any authenticated user with access to the app may call this. + */ +public class GetUserFeatureFlagsReactor extends AbstractReactor { + + private static final String APP_KEY = "app"; + + public GetUserFeatureFlagsReactor() { + this.keysToGet = new String[] { APP_KEY }; + } + + @Override + public NounMetadata execute() { + organizeKeys(); + User user = this.insight.getUser(); + + String appId = this.keyValue.get(APP_KEY); + if (appId == null || appId.isEmpty()) { + throw new IllegalArgumentException("'app' is required"); + } + + if (!AppFeatureFlagUtils.canEvaluateFlags(user, appId)) { + throw new IllegalArgumentException("User does not have access to this app"); + } + + Map flags = AppFeatureFlagUtils.getUserFeatureFlags(appId, user); + return new NounMetadata(flags, PixelDataType.MAP); + } +} diff --git a/src/prerna/reactor/featuregate/RemoveUserFromFeatureFlagReactor.java b/src/prerna/reactor/featuregate/RemoveUserFromFeatureFlagReactor.java new file mode 100644 index 00000000000..759bba1a0cd --- /dev/null +++ b/src/prerna/reactor/featuregate/RemoveUserFromFeatureFlagReactor.java @@ -0,0 +1,82 @@ +/******************************************************************************* + * Copyright 2015 Defense Health Agency (DHA) + * + * If your use of this software does not include any GPLv2 components: + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * ---------------------------------------------------------------------------- + * If your use of this software includes any GPLv2 components: + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + *******************************************************************************/ +package prerna.reactor.featuregate; + +import prerna.auth.User; +import prerna.auth.utils.AppFeatureFlagUtils; +import prerna.reactor.AbstractReactor; +import prerna.sablecc2.om.PixelDataType; +import prerna.sablecc2.om.PixelOperationType; +import prerna.sablecc2.om.nounmeta.NounMetadata; + +/** + * Removes a user from a feature flag. User falls back to the flag's + * defaultVersion. + * + * Pixel: RemoveUserFromFeatureFlag(app="myApp", flagId="uuid", user="jsmith") + * + * Requires app owner or admin. + */ +public class RemoveUserFromFeatureFlagReactor extends AbstractReactor { + + private static final String APP_KEY = "app"; + private static final String FLAG_ID_PARAM = "flagId"; + private static final String USER_KEY = "user"; + + public RemoveUserFromFeatureFlagReactor() { + this.keysToGet = new String[] { APP_KEY, FLAG_ID_PARAM, USER_KEY }; + } + + @Override + public NounMetadata execute() { + organizeKeys(); + User user = this.insight.getUser(); + + String appId = this.keyValue.get(APP_KEY); + String flagId = this.keyValue.get(FLAG_ID_PARAM); + String userId = this.keyValue.get(USER_KEY); + + if (appId == null || appId.isEmpty()) { + throw new IllegalArgumentException("'app' is required"); + } + if (flagId == null || flagId.isEmpty()) { + throw new IllegalArgumentException("'flagId' is required"); + } + if (userId == null || userId.isEmpty()) { + throw new IllegalArgumentException("'user' is required"); + } + + if (!AppFeatureFlagUtils.canManageFlags(user, appId)) { + throw new IllegalArgumentException("User does not have permission to manage feature flags for this app"); + } + + AppFeatureFlagUtils.removeUserFromFlag(appId, flagId, userId); + + return new NounMetadata(true, PixelDataType.BOOLEAN, PixelOperationType.OPERATION); + } +} diff --git a/src/prerna/reactor/featuregate/SetUserAppVersionReactor.java b/src/prerna/reactor/featuregate/SetUserAppVersionReactor.java new file mode 100644 index 00000000000..dc23569f791 --- /dev/null +++ b/src/prerna/reactor/featuregate/SetUserAppVersionReactor.java @@ -0,0 +1,106 @@ +/******************************************************************************* + * Copyright 2015 Defense Health Agency (DHA) + * + * If your use of this software does not include any GPLv2 components: + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * ---------------------------------------------------------------------------- + * If your use of this software includes any GPLv2 components: + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + *******************************************************************************/ +package prerna.reactor.featuregate; + +import java.util.List; + +import prerna.auth.User; +import prerna.auth.utils.AppFeatureFlagUtils; +import prerna.reactor.AbstractReactor; +import prerna.sablecc2.om.GenRowStruct; +import prerna.sablecc2.om.PixelDataType; +import prerna.sablecc2.om.PixelOperationType; +import prerna.sablecc2.om.nounmeta.NounMetadata; + +/** + * Assigns one or more users to a version bucket within an app. + * + * Pixel: SetUserAppVersion(app="SEMOSS", flagId="uuid", + * users=["alice","bob","carol"], version=1) + * + * Requires app owner or admin. + */ +public class SetUserAppVersionReactor extends AbstractReactor { + + private static final String APP_KEY = "app"; + private static final String FLAG_ID_PARAM = "flagId"; + private static final String USERS_KEY = "users"; + private static final String VERSION_KEY = "version"; + + public SetUserAppVersionReactor() { + this.keysToGet = new String[] { APP_KEY, FLAG_ID_PARAM, USERS_KEY, VERSION_KEY }; + } + + @Override + public NounMetadata execute() { + organizeKeys(); + User user = this.insight.getUser(); + + String appId = this.keyValue.get(APP_KEY); + String flagId = this.keyValue.get(FLAG_ID_PARAM); + String versionStr = this.keyValue.get(VERSION_KEY); + + if (appId == null || appId.isEmpty()) { + throw new IllegalArgumentException("'app' is required"); + } + if (flagId == null || flagId.isEmpty()) { + throw new IllegalArgumentException("'flagId' is required"); + } + if (versionStr == null || versionStr.isEmpty()) { + throw new IllegalArgumentException("'version' is required"); + } + + List userIds = getUserIds(); + if (userIds == null || userIds.isEmpty()) { + throw new IllegalArgumentException("'users' is required and must contain at least one user id"); + } + + if (!AppFeatureFlagUtils.canManageFlags(user, appId)) { + throw new IllegalArgumentException("User does not have permission to set user versions for this app"); + } + + int version; + try { + version = Integer.parseInt(versionStr); + } catch (NumberFormatException e) { + throw new IllegalArgumentException("'version' must be an integer"); + } + + AppFeatureFlagUtils.setUserVersions(appId, flagId, userIds, version); + + return new NounMetadata(true, PixelDataType.BOOLEAN, PixelOperationType.OPERATION); + } + + private List getUserIds() { + GenRowStruct grs = this.store.getGenRowStruct(USERS_KEY); + if (grs != null && !grs.isEmpty()) { + return grs.getAllStrValues(); + } + return null; + } +} diff --git a/src/prerna/reactor/featuregate/UpdateAppFeatureFlagReactor.java b/src/prerna/reactor/featuregate/UpdateAppFeatureFlagReactor.java new file mode 100644 index 00000000000..ec3979431b7 --- /dev/null +++ b/src/prerna/reactor/featuregate/UpdateAppFeatureFlagReactor.java @@ -0,0 +1,102 @@ +/******************************************************************************* + * Copyright 2015 Defense Health Agency (DHA) + * + * If your use of this software does not include any GPLv2 components: + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * ---------------------------------------------------------------------------- + * If your use of this software includes any GPLv2 components: + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + *******************************************************************************/ +package prerna.reactor.featuregate; + +import prerna.auth.User; +import prerna.auth.utils.AppFeatureFlagUtils; +import prerna.reactor.AbstractReactor; +import prerna.sablecc2.om.PixelDataType; +import prerna.sablecc2.om.PixelOperationType; +import prerna.sablecc2.om.nounmeta.NounMetadata; + +/** + * Sets the minimum version rule for an existing feature flag, and optionally + * updates its default value. + * + * Pixel: UpdateAppFeatureFlag(app="myApp", flagId="uuid", minVersion=2, + * default=false) + * + * Requires app owner or admin. + */ +public class UpdateAppFeatureFlagReactor extends AbstractReactor { + + private static final String APP_KEY = "app"; + private static final String FLAG_ID_PARAM = "flagId"; + private static final String MIN_VERSION_KEY = "minVersion"; + private static final String DEFAULT_VERSION_KEY = "defaultVersion"; + + public UpdateAppFeatureFlagReactor() { + this.keysToGet = new String[] { APP_KEY, FLAG_ID_PARAM, MIN_VERSION_KEY, DEFAULT_VERSION_KEY }; + } + + @Override + public NounMetadata execute() { + organizeKeys(); + User user = this.insight.getUser(); + + String appId = this.keyValue.get(APP_KEY); + String flagId = this.keyValue.get(FLAG_ID_PARAM); + String minVersionStr = this.keyValue.get(MIN_VERSION_KEY); + String defaultVersionStr = this.keyValue.get(DEFAULT_VERSION_KEY); + + if (appId == null || appId.isEmpty()) { + throw new IllegalArgumentException("'app' is required"); + } + if (flagId == null || flagId.isEmpty()) { + throw new IllegalArgumentException("'flagId' is required"); + } + if ((minVersionStr == null || minVersionStr.isEmpty()) + && (defaultVersionStr == null || defaultVersionStr.isEmpty())) { + throw new IllegalArgumentException("At least one of 'minVersion' or 'defaultVersion' is required"); + } + if (!AppFeatureFlagUtils.canManageFlags(user, appId)) { + throw new IllegalArgumentException("User does not have permission to manage feature flags for this app"); + } + + Integer minVersion = null; + if (minVersionStr != null && !minVersionStr.isEmpty()) { + try { + minVersion = Integer.parseInt(minVersionStr); + } catch (NumberFormatException e) { + throw new IllegalArgumentException("'minVersion' must be an integer"); + } + } + Integer defaultVersion = null; + if (defaultVersionStr != null && !defaultVersionStr.isEmpty()) { + try { + defaultVersion = Integer.parseInt(defaultVersionStr); + } catch (NumberFormatException e) { + throw new IllegalArgumentException("'defaultVersion' must be an integer"); + } + } + + AppFeatureFlagUtils.updateFlag(appId, flagId, minVersion, defaultVersion); + + return new NounMetadata(true, PixelDataType.BOOLEAN, PixelOperationType.OPERATION); + } +} diff --git a/src/prerna/reactor/featuregate/UpdateAppVersionBucketReactor.java b/src/prerna/reactor/featuregate/UpdateAppVersionBucketReactor.java new file mode 100644 index 00000000000..b045f9a56d4 --- /dev/null +++ b/src/prerna/reactor/featuregate/UpdateAppVersionBucketReactor.java @@ -0,0 +1,93 @@ +/******************************************************************************* + * Copyright 2015 Defense Health Agency (DHA) + * + * If your use of this software does not include any GPLv2 components: + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * ---------------------------------------------------------------------------- + * If your use of this software includes any GPLv2 components: + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + *******************************************************************************/ +package prerna.reactor.featuregate; + +import prerna.auth.User; +import prerna.auth.utils.AppFeatureFlagUtils; +import prerna.reactor.AbstractReactor; +import prerna.sablecc2.om.PixelDataType; +import prerna.sablecc2.om.PixelOperationType; +import prerna.sablecc2.om.nounmeta.NounMetadata; + +/** + * Updates only the description for an existing version bucket. + * + * Pixel: UpdateAppVersionBucket(app="myApp", flagId="uuid", version=2, + * description="Updated description") + * + * Requires app owner or admin. + */ +public class UpdateAppVersionBucketReactor extends AbstractReactor { + + private static final String APP_KEY = "app"; + private static final String FLAG_ID_PARAM = "flagId"; + private static final String VERSION_KEY = "version"; + + public UpdateAppVersionBucketReactor() { + this.keysToGet = new String[] { APP_KEY, FLAG_ID_PARAM, VERSION_KEY }; + } + + @Override + public NounMetadata execute() { + organizeKeys(); + User user = this.insight.getUser(); + + String appId = this.keyValue.get(APP_KEY); + String flagId = this.keyValue.get(FLAG_ID_PARAM); + String versionStr = this.keyValue.get(VERSION_KEY); + String description = this.keyValue.get("description"); + if (description == null) { + description = ""; + } + + if (appId == null || appId.isEmpty()) { + throw new IllegalArgumentException("'app' is required"); + } + if (flagId == null || flagId.isEmpty()) { + throw new IllegalArgumentException("'flagId' is required"); + } + if (versionStr == null || versionStr.isEmpty()) { + throw new IllegalArgumentException("'version' is required"); + } + + int version; + try { + version = Integer.parseInt(versionStr); + } catch (NumberFormatException e) { + throw new IllegalArgumentException("'version' must be an integer"); + } + + if (!AppFeatureFlagUtils.canManageFlags(user, appId)) { + throw new IllegalArgumentException("User does not have permission to manage feature flags for this app"); + } + + AppFeatureFlagUtils.updateVersionBucketDescription(appId, flagId, version, description); + + return new NounMetadata(true, PixelDataType.BOOLEAN, PixelOperationType.OPERATION); + } +} \ No newline at end of file From b36eaed9b7bb882b510c6734b4025f892f1bff89 Mon Sep 17 00:00:00 2001 From: travon-speller Date: Tue, 23 Jun 2026 13:18:31 -0400 Subject: [PATCH 2/3] feat: update getUserFeature --- docs/feature-gate-design.md | 19 +++++++++- .../auth/utils/AppFeatureFlagUtils.java | 37 +++++++++++++++++++ .../GetUserFeatureFlagsReactor.java | 8 ++-- 3 files changed, 60 insertions(+), 4 deletions(-) diff --git a/docs/feature-gate-design.md b/docs/feature-gate-design.md index 234d4b70291..0bbac00b27f 100644 --- a/docs/feature-gate-design.md +++ b/docs/feature-gate-design.md @@ -99,7 +99,24 @@ App owners use these to create and manage flags. All live under `prerna/reactor/ | Reactor | Pixel Call | Description | |---|---|---| | `CheckFeatureFlagReactor` | `CheckFeatureFlag(app, flagId)` | Evaluate: does current user have flag enabled? Returns boolean | -| `GetUserFeatureFlagsReactor` | `GetUserFeatureFlags(app)` | Returns all flags (by flagKey) and their evaluated state for the current user — app can handle checks client-side | +| `GetUserFeatureFlagsReactor` | `GetUserFeatureFlags(app)` | Returns all flags (by flagKey) with evaluation details for the current user, including `enabled`, `userVersion`, `defaultVersion`, `effectiveVersion`, and `minVersion` | + +Example response shape: + +```json +{ + "new-dashboard": { + "flagId": "uuid", + "enabled": true, + "userVersion": 5, + "defaultVersion": 0, + "effectiveVersion": 5, + "minVersion": 3 + } +} +``` + +`effectiveVersion` is the version actually used for evaluation. If the user has an explicit version assignment for the flag, it matches `userVersion`. Otherwise it falls back to `defaultVersion`. ### Evaluation Logic — `AppFeatureFlagUtils.java` diff --git a/src/prerna/auth/utils/AppFeatureFlagUtils.java b/src/prerna/auth/utils/AppFeatureFlagUtils.java index 9dbfef9826a..ee2649ddf7c 100644 --- a/src/prerna/auth/utils/AppFeatureFlagUtils.java +++ b/src/prerna/auth/utils/AppFeatureFlagUtils.java @@ -389,6 +389,43 @@ public static Map getUserFeatureFlags(String appId, User user) return result; } + public static Map> getUserFeatureFlagDetails(String appId, User user) { + IRDBMSEngine securityDb = SystemEngineRegistry.getSecurityDb(); + Map> result = new HashMap<>(); + String userId = getUserId(user); + + SelectQueryStruct qs = new SelectQueryStruct(); + qs.addSelector(new QueryColumnSelector("APP_FEATURE_FLAG__FLAG_ID")); + qs.addSelector(new QueryColumnSelector("APP_FEATURE_FLAG__FLAG_KEY")); + qs.addSelector(new QueryColumnSelector("APP_FEATURE_FLAG__MIN_VERSION")); + qs.addSelector(new QueryColumnSelector("APP_FEATURE_FLAG__DEFAULT_VERSION")); + qs.addExplicitFilter(SimpleQueryFilter.makeColToValFilter("APP_FEATURE_FLAG__APP_ID", "==", appId)); + + try (IRawSelectWrapper wrapper = WrapperManager.getInstance().getRawWrapper(securityDb, qs)) { + while (wrapper.hasNext()) { + Object[] row = wrapper.next().getValues(); + String flagId = (String) row[0]; + String flagKey = (String) row[1]; + int minVersion = row[2] != null ? ((Number) row[2]).intValue() : 0; + int defaultVersion = row[3] != null ? ((Number) row[3]).intValue() : 0; + int userVersion = getUserVersion(appId, flagId, userId); + int effectiveVersion = userVersion >= 0 ? userVersion : defaultVersion; + + Map detail = new HashMap<>(); + detail.put("flagId", flagId); + detail.put("enabled", effectiveVersion >= minVersion); + detail.put("userVersion", userVersion); + detail.put("defaultVersion", defaultVersion); + detail.put("effectiveVersion", effectiveVersion); + detail.put("minVersion", minVersion); + result.put(flagKey, detail); + } + } catch (Exception e) { + classLogger.error("Error fetching flag details for bulk evaluation, app={}", appId, e); + } + return result; + } + public static Map> getVersionBucketsWithDetails(String appId, String flagId) { IRDBMSEngine securityDb = SystemEngineRegistry.getSecurityDb(); Map> buckets = new HashMap<>(); diff --git a/src/prerna/reactor/featuregate/GetUserFeatureFlagsReactor.java b/src/prerna/reactor/featuregate/GetUserFeatureFlagsReactor.java index 243de347122..91e1a8e6426 100644 --- a/src/prerna/reactor/featuregate/GetUserFeatureFlagsReactor.java +++ b/src/prerna/reactor/featuregate/GetUserFeatureFlagsReactor.java @@ -36,8 +36,10 @@ import prerna.sablecc2.om.nounmeta.NounMetadata; /** - * Returns all feature flag keys and their evaluated boolean state for the current user. - * The app can handle flag checks client-side using this response. + * Returns all feature flag keys and their evaluated state details for the + * current user. + * Each flag includes whether it is enabled and the version data used to + * evaluate it. * * Pixel: GetUserFeatureFlags(app="myApp") * @@ -65,7 +67,7 @@ public NounMetadata execute() { throw new IllegalArgumentException("User does not have access to this app"); } - Map flags = AppFeatureFlagUtils.getUserFeatureFlags(appId, user); + Map> flags = AppFeatureFlagUtils.getUserFeatureFlagDetails(appId, user); return new NounMetadata(flags, PixelDataType.MAP); } } From 55e9cc9a4430039f15eb69af504e610e4a6dfde1 Mon Sep 17 00:00:00 2001 From: travon-speller Date: Tue, 23 Jun 2026 13:48:51 -0400 Subject: [PATCH 3/3] fix: update reactor to allow update to description and fix example calls --- docs/feature-gate-design.md | 5 +++-- src/prerna/auth/utils/AppFeatureFlagUtils.java | 14 +++++++++++--- .../featuregate/CreateAppFeatureFlagReactor.java | 2 +- .../featuregate/UpdateAppFeatureFlagReactor.java | 16 ++++++++++------ 4 files changed, 25 insertions(+), 12 deletions(-) diff --git a/docs/feature-gate-design.md b/docs/feature-gate-design.md index 0bbac00b27f..662f96f3875 100644 --- a/docs/feature-gate-design.md +++ b/docs/feature-gate-design.md @@ -74,7 +74,7 @@ App owners use these to create and manage flags. All live under `prerna/reactor/ | Reactor | Pixel Call | Description | |---|---|---| | `CreateAppFeatureFlagReactor` | `CreateAppFeatureFlag(app, key, description)` | Define a new flag for an app (returns flagId) | -| `UpdateAppFeatureFlagReactor` | `UpdateAppFeatureFlag(app, flagId, minVersion, defaultVersion)` | Set which version enables the flag and default for unassigned users | +| `UpdateAppFeatureFlagReactor` | `UpdateAppFeatureFlag(app, flagId, minVersion?, defaultVersion?, description?)` | Update one or more flag properties: minVersion threshold, defaultVersion fallback, and description | | `DeleteAppFeatureFlagReactor` | `DeleteAppFeatureFlag(app, flagId)` | Remove a flag and cascade to version buckets and user assignments | | `GetAppVersionBucketsReactor` | `GetAppVersionBuckets(app, flagId)` | List all version buckets for a flag with descriptions and user lists | @@ -185,7 +185,8 @@ UpdateAppFeatureFlag( app = "myApp", flagId = "550e8400-e29b-41d4-a716-446655440000", minVersion = 2, // flag enabled for v2 and above - defaultVersion = 0 // unassigned users see v0 (disabled) + defaultVersion = 0, // unassigned users see v0 (disabled) + description = "Rollout after beta validation" ); diff --git a/src/prerna/auth/utils/AppFeatureFlagUtils.java b/src/prerna/auth/utils/AppFeatureFlagUtils.java index ee2649ddf7c..b364770d774 100644 --- a/src/prerna/auth/utils/AppFeatureFlagUtils.java +++ b/src/prerna/auth/utils/AppFeatureFlagUtils.java @@ -162,9 +162,11 @@ public static String createFlag(String appId, String flagKey, String description return flagId; } - public static void updateFlag(String appId, String flagId, Integer minVersion, Integer defaultVersion) { - if (minVersion == null && defaultVersion == null) { - throw new IllegalArgumentException("At least one of 'minVersion' or 'defaultVersion' is required"); + public static void updateFlag(String appId, String flagId, Integer minVersion, Integer defaultVersion, + String description) { + if (minVersion == null && defaultVersion == null && description == null) { + throw new IllegalArgumentException( + "At least one of 'minVersion', 'defaultVersion', or 'description' is required"); } IRDBMSEngine securityDb = SystemEngineRegistry.getSecurityDb(); StringBuilder sql = new StringBuilder("UPDATE APP_FEATURE_FLAG SET "); @@ -179,6 +181,12 @@ public static void updateFlag(String appId, String flagId, Integer minVersion, I sql.append("DEFAULT_VERSION=?"); params.add(defaultVersion); } + if (description != null) { + if (!params.isEmpty()) + sql.append(", "); + sql.append("DESCRIPTION=?"); + params.add(description); + } sql.append(" WHERE APP_ID=? AND FLAG_ID=?"); params.add(appId); params.add(flagId); diff --git a/src/prerna/reactor/featuregate/CreateAppFeatureFlagReactor.java b/src/prerna/reactor/featuregate/CreateAppFeatureFlagReactor.java index f9982beb0ce..b27ff8d7a17 100644 --- a/src/prerna/reactor/featuregate/CreateAppFeatureFlagReactor.java +++ b/src/prerna/reactor/featuregate/CreateAppFeatureFlagReactor.java @@ -38,7 +38,7 @@ * Creates a new feature flag for an app. * * Pixel: CreateAppFeatureFlag(app="myApp", key="new-dashboard", - * description="Redesigned UI", default=false) + * description="Redesigned UI") * * Requires app owner or admin. */ diff --git a/src/prerna/reactor/featuregate/UpdateAppFeatureFlagReactor.java b/src/prerna/reactor/featuregate/UpdateAppFeatureFlagReactor.java index ec3979431b7..bfb21eefc6e 100644 --- a/src/prerna/reactor/featuregate/UpdateAppFeatureFlagReactor.java +++ b/src/prerna/reactor/featuregate/UpdateAppFeatureFlagReactor.java @@ -36,10 +36,10 @@ /** * Sets the minimum version rule for an existing feature flag, and optionally - * updates its default value. + * updates its default value and description. * * Pixel: UpdateAppFeatureFlag(app="myApp", flagId="uuid", minVersion=2, - * default=false) + * defaultVersion=0, description="Gradual rollout for dashboard") * * Requires app owner or admin. */ @@ -49,9 +49,10 @@ public class UpdateAppFeatureFlagReactor extends AbstractReactor { private static final String FLAG_ID_PARAM = "flagId"; private static final String MIN_VERSION_KEY = "minVersion"; private static final String DEFAULT_VERSION_KEY = "defaultVersion"; + private static final String DESCRIPTION_KEY = "description"; public UpdateAppFeatureFlagReactor() { - this.keysToGet = new String[] { APP_KEY, FLAG_ID_PARAM, MIN_VERSION_KEY, DEFAULT_VERSION_KEY }; + this.keysToGet = new String[] { APP_KEY, FLAG_ID_PARAM, MIN_VERSION_KEY, DEFAULT_VERSION_KEY, DESCRIPTION_KEY }; } @Override @@ -63,6 +64,7 @@ public NounMetadata execute() { String flagId = this.keyValue.get(FLAG_ID_PARAM); String minVersionStr = this.keyValue.get(MIN_VERSION_KEY); String defaultVersionStr = this.keyValue.get(DEFAULT_VERSION_KEY); + String description = this.keyValue.get(DESCRIPTION_KEY); if (appId == null || appId.isEmpty()) { throw new IllegalArgumentException("'app' is required"); @@ -71,8 +73,10 @@ public NounMetadata execute() { throw new IllegalArgumentException("'flagId' is required"); } if ((minVersionStr == null || minVersionStr.isEmpty()) - && (defaultVersionStr == null || defaultVersionStr.isEmpty())) { - throw new IllegalArgumentException("At least one of 'minVersion' or 'defaultVersion' is required"); + && (defaultVersionStr == null || defaultVersionStr.isEmpty()) + && description == null) { + throw new IllegalArgumentException( + "At least one of 'minVersion', 'defaultVersion', or 'description' is required"); } if (!AppFeatureFlagUtils.canManageFlags(user, appId)) { throw new IllegalArgumentException("User does not have permission to manage feature flags for this app"); @@ -95,7 +99,7 @@ public NounMetadata execute() { } } - AppFeatureFlagUtils.updateFlag(appId, flagId, minVersion, defaultVersion); + AppFeatureFlagUtils.updateFlag(appId, flagId, minVersion, defaultVersion, description); return new NounMetadata(true, PixelDataType.BOOLEAN, PixelOperationType.OPERATION); }