Restrict User Environment

Current plan level (Free, Team, Business, or Enterprise: Retool | Retool Pricing): Business
Monthly/Annual (if Team or Business):
Version of Retool (if self-hosted):

Question / Description:

We moved to business for several reasons. This being one of the biggest. Want to be able to restrict access of my developers to just the stage environment. While the feature of changing environment from prod to stage and having resources defined for each environment is VERY nice, not being able to restrict a user group (IE: Developers) to one environment means that the potential for messing up actual customer data stripe charge data, etc... is HUGE. it only takes one "oops forgot to change the environment while testing my screen" to have huge impacts. And Vise Versa - users of the app having the ability to mistakenly be using staging or dev data, is a problem because they could inadvertently be using stage or dev data, entering key customer comments or changes, only to be lost when the environment is refreshed and worse - never actually get to the customers credit card or service delivered. Is there a way to do this? Reading a lot on posts and in chat which seems to hint at it being possible but then a lot more that concludes with it's just not possible.

If not, any workaround suggestions?

Hi David,
You are right, it would be a feature we could all benefit from. Right now I don't think there are any native settings that achieve this other than with the Enterprise plan.

As a workaround I have created distinct resources such as "Slack-finish-line-dev" and "Slack-finish-line-prod", then giving a user limit dev permissions for that resource. In your case it should work fine to restrict Stripe.

Thanks. That's where we started but quickly started seeing challenges - for example. A single workflow interacting with stripe to synchronize customer ID's, create and finalize invoices can easily have 5 api calls with stripe and 4 postges interactions. Started off using "stripe_prod", "stripe_test" and "dbs_prod", and "dbs_test". Only challenge is that when you move from test to prod you have to change all of the places in your workflows and screens that consume these resources. The environments on the business plan and above work beautifully to keep for having to re-define each resource. I found a way using the global javascript for the account which loads before any individual app. Due to how retool is hydrating current_user and retoolContext, when they are hydrating those and how the boot for the global javascript loads, this wasn't quite how i wanted it but works. Here's what we ended up doing:

  1. We have a global navigation menu item defined as a module which is loaded on every page / app we have. That gives us the consistent look and feel. We were able to leverage this to define an html component that tells the user they are not in the correct environment.

//
`

INVALID ENVIRONMENT FOR YOUR ROLE
Please switch using the selector at the bottom left of the page.
Gandalf says no
`

with CSS (style)
.access-overlay {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background: rgba(0, 0, 0, 0.95);
z-index: 9999;
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
animation: fadeInGandalf 2s ease-out forwards;
overflow: hidden;
}

@keyframes fadeInGandalf {
to {
opacity: 1;
}
}

.flash-bg {
position: absolute;
width: 100%;
height: 100%;
background: white;
opacity: 0;
animation: flashEffect 1s ease-in-out 0.5s;
pointer-events: none;
}

@keyframes flashEffect {
0% { opacity: 0; }
10% { opacity: 1; }
20% { opacity: 0; }
30% { opacity: 0.8; }
40% { opacity: 0; }
100% { opacity: 0; }
}

.warning-box {
text-align: center;
color: #fff3cd;
background: rgba(255, 243, 205, 0.1);
padding: 40px;
border: 2px solid #ffa000;
border-radius: 12px;
box-shadow: 0 0 40px rgba(255, 243, 205, 0.8);
font-size: 24px;
font-weight: bold;
max-width: 90%;
animation: pulseGlow 3s infinite;
}

@keyframes pulseGlow {
0%, 100% { box-shadow: 0 0 40px rgba(255, 243, 205, 0.8); }
50% { box-shadow: 0 0 60px rgba(255, 243, 205, 1); }
}

.shake {
animation: shakeScreen 0.6s ease-in-out 2 alternate;
}

@keyframes shakeScreen {
0% { transform: translate(0px, 0px); }
20% { transform: translate(-10px, 6px); }
40% { transform: translate(10px, -6px); }
60% { transform: translate(-8px, 8px); }
80% { transform: translate(8px, -8px); }
100% { transform: translate(0px, 0px); }
}

.message-text {
font-size: 28px;
line-height: 1.6;
margin-bottom: 12px;
color: #fff6da;
}

.sub-text {
font-size: 18px;
line-height: 1.5;
color: #fceeb5;
}

.gandalf-gif {
margin-top: 24px;
width: 360px;
max-width: 100%;
height: auto;
border-radius: 6px;
box-shadow: 0 0 16px rgba(255, 255, 255, 0.7);
}

@media (max-width: 500px) {
.warning-box {
font-size: 18px;
padding: 20px;
}

.message-text {
  font-size: 20px;
}

.sub-text {
  font-size: 16px;
}

.gandalf-gif {
  width: 240px;
}

}

  1. Defined the following global function to do the rule enforcement. (user -> settings ->advanced settings -> preload javascript

window.domain_security = ({ current_user, retoolContext }) => {
try {
const env = retoolContext.environment?.toLowerCase();
const email = current_user.email?.toLowerCase();

// Normalize groups from object form: [{ name: "Editor" }, ...]
const groups = Array.isArray(current_user.groups)
  ? current_user.groups.map(g => g.name?.toLowerCase())
  : [];

const isTesting = groups.includes('testing');
const isEditor = groups.includes('editor');
const isViewer = groups.includes('viewer');

const accessMatrix = {
  admin: ['staging','production'],
  editor: ['staging'],
  viewer: ['production'],
  Testing: ['staging', 'production'] // testing can access both, but shows banner
};

const allowedEnvs = groups.flatMap(group => accessMatrix[group] || []);
const isRestricted = !allowedEnvs.includes(env);

// 🔁 Dynamic banner text for Testing users
const bannerText = isTesting && env
  ? `${env.toUpperCase()} TESTING`
  : null;

return {
  email,
  groups,
  environment: env,
  isRestricted,
  isTesting,
  bannerText
};

} catch {
return {
email: null,
groups: ,
environment: null,
isRestricted: true,
isTesting: false,
bannerText: null
};
}
};

Which returns this json
{email: "email@domain",
groups: Array(3) (for all the groups the user is a part of)
"admin"
"all users"
"testing"
environment: "production"
isRestricted: false (meaning they are in the wrong environment)
isTesting: true (meaning they are a part of the test group which will allow them to be in the specified environments but makes the background of the global nav menu object RED for an obvious indication that something is different / wrong"
bannerText: "PRODUCTION TESTING". - Thought about having a floating banner on the page as an annoying bubble but could not easily get this to work. may do that at some point.

  1. On our global navigation module defined the following JS script to execute on page load (named getSecurity) and set to automatically run (advanced settings checkbox)

const ctx = domain_security({ current_user, retoolContext });
return ctx;// computeDomainSecurity

  1. set a return value on the module named securityDomain for interrogation on each page should i needed it. Helpful for debugging. value of getSecurity.data. Key here was to have the js script run. just embedding the domain_security call in the return value would not trigger it because neither current_user or retoolContext changes forcing a re-evaluation. This had to be done by setting getSecurity js script to run automatically on page load.

  2. set the visibility to our html control (hidden field) to be the !getSecurity.data.isRestricted value.

  3. set the background of our global menu module to {{ getSecurity.data.isTesting === true ? "red" : 000000 }}

this made it very easy and very usable. the environments allowed us to define the resources for stripe, postgres and our other api's (home grown restful) for dev, test, prod, staging, dynamically with the built-in features from retool. the global js, enforces rules as to who can be where. the global navigation module gives a simple hook to plant in each app or page which enforces the rules and redirects the user to go to the environment they should be in or that they are a tester and should be very careful to look at the environment they are using. Making a user (even a developer) a tester is as easy as adding them to or subtracting them from the Testing group. if they are an editor then they are restricted to the dev or staging environments and get a nice little html that "they shall not pass" b/c they are in the wrong environment. Same for viewers. True, the developer just needs to drop the global menu item and they can get around it but it's really meant to make sure they know what environment they are in and don't make an inadvertent mistake of being in the wrong environment when testing data.

1 Like

Nice workaround. It will save everyone time and is a nice guardrail. Anytime you can use Gandalf, you should! :mage:

I love the creativity, @David_Bolen! The environment toggle in the UI can be easy to miss, so this is an effective workaround. :rofl:

The core functionality that you're asking about is currently scoped to Enterprise plans, which isn't made abundantly clear in our docs.

There's a few internal conversations on this topic that I can bump on your behalf, though. Let me know if you have any additional questions!

Thanks Daren.

I didn’t see that. I notice the words self-hosted.

I don’t think we are ready for that level of expense yet, but we would like to look at moving to a self-hosted version.

Who can help me with that?

Dave Bolen, COO | PowderWatts
O: 801-477-7756 | C: 816-492-0467 | davebolen@powderwatts.com

285 N Main St #302, Kaysville, UT 84037

Hi Darren, for companies like us who really depend on being able to restrict certain devs from prod-access but unable to go from business to enterprise plan, do you see a workaround how to achieve that level of security? E.g. we would be fine by having to create duplicate resources without prod-env access and to switch to a non-prod resource every time a limited dev works on it, and switching back to a prod-resource before final release. But that’s currently not possible: once a limited dev doesn’t have edit permissions on the prod-resource, he can’t switch the resource in the app editor to the corresponding non-prod resource. Any help would be much appreciated. And again, enterprise plan is not an option. We would be willing to pay extra for that feature, but not that much more :wink:

I second what rafael says! My workaround works pretty well Rafael. it gives a fun little warning, but have since been able to push them or even production users (Customer Service Reps) or "interested third party board advisors) to say a demo environment. Restricting the developers to editors means they can't muck with resources or the global settings like libraries or global JS. Which is where i implemented the logic to restrict them from any environment other than the ones I want them in. I define each resource (IE Postgres, Stripe, SF, Hubspot, our back end custom services) for each environment with the same resource name. Then with things like postgres queries, make sure things like schema names are NOT included in the query and handle default paths for things like that on the back end for the user i define for the resource. Then, using the global JS it forces them into a specific "environment" and does not allow them to use the resource / environment that they are not permitted to use or see.

I'm happy to get you started and answer any initial questions, @David_Bolen, if that's a route that you're interested in! You can get a free license key and start doing some initial testing over here.

And @rafael_cx, I'll make sure your to include your feedback in the conversations that we currently have going internally. If David's workaround isn't adequate and you truly want to restrict access to certain resources, the best way to do this is by passing data about the current user with each request and enforcing access at the server or database level.

Hi Darren, how would you do this?

the best way to do this is by passing data about the current user with each request and enforcing access at the server or database level.

For HTTP resource e.g. I could pass the current_user as header. But for Postgres resources, how would I pass any user information to the db?

There would need to be some sort of proxy server sitting in front of your database in order to do this, unfortunately.