<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Srikanth reddy kasa</title>
    <description>The latest articles on DEV Community by Srikanth reddy kasa (@srikanth-supero).</description>
    <link>https://dev.to/srikanth-supero</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4112656%2Fe06bb515-cc1e-4add-bbba-3f0994081968.png</url>
      <title>DEV Community: Srikanth reddy kasa</title>
      <link>https://dev.to/srikanth-supero</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/srikanth-supero"/>
    <language>en</language>
    <item>
      <title>Here's my app's access control, the passwords, and a curl command. Go break it</title>
      <dc:creator>Srikanth reddy kasa</dc:creator>
      <pubDate>Sun, 06 Sep 2026 20:39:37 +0000</pubDate>
      <link>https://dev.to/srikanth-supero/heres-my-apps-access-control-the-passwords-and-a-curl-command-go-break-it-2d50</link>
      <guid>https://dev.to/srikanth-supero/heres-my-apps-access-control-the-passwords-and-a-curl-command-go-break-it-2d50</guid>
      <description>&lt;p&gt;I moved my app's authorization below the application code. Here is the live demo, the passwords, and the curl commands to try to break it.&lt;/p&gt;

&lt;p&gt;Every B2B app I built before this one put authorization &lt;em&gt;in&lt;/em&gt; the application code. A decorator here, an &lt;code&gt;if user.role == 'admin'&lt;/code&gt; there, a query that remembers to add &lt;code&gt;WHERE tenant_id = ?&lt;/code&gt;. It works, right up until one endpoint forgets, and then it is a data leak with a CVE number.&lt;/p&gt;

&lt;p&gt;The thing that bothers me is that this is &lt;em&gt;re-litigated in every codebase&lt;/em&gt;. The rule "policyholders must never see the fraud score" is a business fact. It ends up encoded across a serializer, three endpoints and a React component, and nothing structurally prevents the fourth endpoint from getting it wrong.&lt;/p&gt;

&lt;p&gt;So I built the other version: the rules are a declaration, and the server applies them in front of the database, before application code runs.&lt;/p&gt;

&lt;p&gt;That is an easy claim to make and a cheap one to fake. So here is a live app, the passwords, and the commands to check.&lt;/p&gt;




&lt;h2&gt;
  
  
  The app
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://sentinel.supero.live" rel="noopener noreferrer"&gt;Sentinel&lt;/a&gt; is an insurance claims system — policies, claims intake, adjuster review with fraud scoring, documents, and separate portals for policyholders and staff.&lt;/p&gt;

&lt;p&gt;It runs &lt;strong&gt;two insurers on one deployment&lt;/strong&gt;: Northwind Mutual and Cascade Assurance. Four logins, all with the password &lt;code&gt;Password123!&lt;/code&gt;:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tenant&lt;/th&gt;
&lt;th&gt;Role&lt;/th&gt;
&lt;th&gt;Email&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Northwind Mutual&lt;/td&gt;
&lt;td&gt;Policyholder&lt;/td&gt;
&lt;td&gt;&lt;code&gt;member@sentinel.insure&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Northwind Mutual&lt;/td&gt;
&lt;td&gt;Claims team&lt;/td&gt;
&lt;td&gt;&lt;code&gt;claims@sentinel.insure&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cascade Assurance&lt;/td&gt;
&lt;td&gt;Policyholder&lt;/td&gt;
&lt;td&gt;&lt;code&gt;member@cascade.insure&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cascade Assurance&lt;/td&gt;
&lt;td&gt;Claims team&lt;/td&gt;
&lt;td&gt;&lt;code&gt;claims@cascade.insure&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The data is fictional and gets reset. Please don't put anything real in it.&lt;/p&gt;

&lt;p&gt;There are two separate things to try to break: &lt;strong&gt;fields&lt;/strong&gt; (can a policyholder get the fraud score?) and &lt;strong&gt;tenants&lt;/strong&gt; (can Northwind get Cascade's book of business?).&lt;/p&gt;




&lt;h2&gt;
  
  
  Test 1: the field the client never receives
&lt;/h2&gt;

&lt;p&gt;Adjusters see &lt;code&gt;fraud_score&lt;/code&gt; and &lt;code&gt;internal_notes&lt;/code&gt; on every claim. Policyholders must never see either. Log in as the policyholder and read the claims:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;API&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;https://api.supero.dev

login&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
  curl &lt;span class="nt"&gt;-sS&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$API&lt;/span&gt;&lt;span class="s2"&gt;/api/v1/auth/login"&lt;/span&gt; &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s1"&gt;'Content-Type: application/json'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s2"&gt;"{&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;domain_name&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;:&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;supero-apps&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;,&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;project&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;:&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;sentinel&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;,
         &lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;email&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;:&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="nv"&gt;$1&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;,&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;password&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;:&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;Password123!&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;}"&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;

&lt;span class="nv"&gt;TOKEN&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;login member@sentinel.insure | jq &lt;span class="nt"&gt;-r&lt;/span&gt; .auth.access_token&lt;span class="si"&gt;)&lt;/span&gt;

curl &lt;span class="nt"&gt;-sS&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$API&lt;/span&gt;&lt;span class="s2"&gt;/api/v1/crud/supero-apps/sentinel:claim"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &lt;/span&gt;&lt;span class="nv"&gt;$TOKEN&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
| jq &lt;span class="s1"&gt;'{claims: .result_count,
       fraud_score:    [.results[] | has("fraud_score")]    | any,
       internal_notes: [.results[] | has("internal_notes")] | any}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"claims"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;9&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"fraud_score"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"internal_notes"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now change one word — &lt;code&gt;member&lt;/code&gt; to &lt;code&gt;claims&lt;/code&gt; — and run it again:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"claims"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;9&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"fraud_score"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"internal_notes"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same endpoint, same query, no filter parameter. The fields are &lt;strong&gt;not in the JSON&lt;/strong&gt; for the policyholder. Not &lt;code&gt;display: none&lt;/code&gt;, not dropped by the frontend — never sent. Open devtools on the live demo and they are not in the network tab either.&lt;/p&gt;

&lt;h2&gt;
  
  
  Test 2: the tenant boundary
&lt;/h2&gt;

&lt;p&gt;Both claims-team accounts are &lt;code&gt;tenant_admin&lt;/code&gt; — the most privileged role in the app. Read claims as each of them:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="k"&gt;for &lt;/span&gt;EMAIL &lt;span class="k"&gt;in &lt;/span&gt;claims@sentinel.insure claims@cascade.insure&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;&lt;span class="nv"&gt;TOKEN&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;login &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$EMAIL&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | jq &lt;span class="nt"&gt;-r&lt;/span&gt; .auth.access_token&lt;span class="si"&gt;)&lt;/span&gt;
  &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$EMAIL&lt;/span&gt;&lt;span class="s2"&gt; -&amp;gt; "&lt;/span&gt;
  curl &lt;span class="nt"&gt;-sS&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$API&lt;/span&gt;&lt;span class="s2"&gt;/api/v1/crud/supero-apps/sentinel:claim"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &lt;/span&gt;&lt;span class="nv"&gt;$TOKEN&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  | jq &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s1"&gt;'[.results[].claim_number] | sort | .[0:3]'&lt;/span&gt;
&lt;span class="k"&gt;done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="err"&gt;claims@sentinel.insure&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"CLM-202047"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s2"&gt;"CLM-203980"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s2"&gt;"CLM-204120"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="err"&gt;claims@cascade.insure&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="err"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"CLM-CA-88214"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s2"&gt;"CLM-CA-88301"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s2"&gt;"CLM-CA-88355"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Northwind numbers its claims &lt;code&gt;CLM-…&lt;/code&gt;, Cascade uses &lt;code&gt;CLM-CA-…&lt;/code&gt; — deliberately, so a leak would be obvious on sight rather than needing a UUID comparison. Neither admin can reach the other's rows. There is no &lt;code&gt;tenant_id&lt;/code&gt; in the request for anyone to tamper with; the tenant is bound to the session at login.&lt;/p&gt;




&lt;h2&gt;
  
  
  Where the rules come from
&lt;/h2&gt;

&lt;p&gt;The login response includes the policy the server issued for that session. The client does not choose it, and cannot alter it — it is shown to the client so the UI knows what to render:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;login member@sentinel.insure | jq &lt;span class="s1"&gt;'.policy.entities["sentinel:claim"]'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json-doc"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"entity"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"sentinel:claim"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"can_create"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"can_read"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"can_update"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"can_delete"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"filter_field"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"owner_username"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;     &lt;/span&gt;&lt;span class="c1"&gt;// row scope&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"filter_match"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"$user.name"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"hidden_fields"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"fraud_score"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"internal_notes"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;   &lt;/span&gt;&lt;span class="c1"&gt;// field scope&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"readonly_fields"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"state"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"created_by"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"tenant_uuid"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c1"&gt;...&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;default_access&lt;/code&gt; for this role is &lt;code&gt;none&lt;/code&gt;; every entity the role can touch is listed explicitly. The claims-team policy is the same document without the &lt;code&gt;filter_field&lt;/code&gt; and &lt;code&gt;hidden_fields&lt;/code&gt; lines.&lt;/p&gt;

&lt;p&gt;The part I find genuinely useful: look at the schema that defines a claim and note what is &lt;em&gt;not&lt;/em&gt; there. &lt;code&gt;fraud_score&lt;/code&gt; is an ordinary integer attribute. Nothing in the data model marks it as secret. The sensitivity is declared separately, in the access policy, and applied by the server. Which means the answer to "who can see the fraud score?" is one file, not a grep across the codebase.&lt;/p&gt;




&lt;h2&gt;
  
  
  The honest part
&lt;/h2&gt;

&lt;p&gt;If you go read the app's own source, you will find role checks in it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;isAdmin&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nf"&gt;h&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;button&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;onClick&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="err"&gt;…&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;🛡 Claims console&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I am not going to pretend those don't exist, because you would find them in a minute and then disbelieve the rest. They decide &lt;strong&gt;what the UI draws&lt;/strong&gt;. They are not what protects the data, and that is the whole point: if I deleted every one of them and shipped the claims console to policyholders, the console would render with the fraud score column empty, because the server still won't send the field. The UI check is a convenience. The enforcement is underneath it.&lt;/p&gt;

&lt;p&gt;That is the difference I was after. In the version I have written five times before, that &lt;code&gt;isAdmin&lt;/code&gt; check &lt;em&gt;was&lt;/em&gt; the security boundary.&lt;/p&gt;




&lt;h2&gt;
  
  
  What this does not show
&lt;/h2&gt;

&lt;p&gt;Being straight about the edges, because you'd find them anyway:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;No field-level encryption here.&lt;/strong&gt; The platform supports encrypted fields; this app doesn't use them, so don't read the above as evidence about encryption at rest.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The demo logins are shared and public&lt;/strong&gt;, and they can write. The data is fictional and reset periodically.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;This is one app's configuration, not an audit.&lt;/strong&gt; I have shown you the two axes I claimed. I have not shown you that every endpoint in the platform is correct, and you should not take a blog post as proof of that.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The UI code is hand-written.&lt;/strong&gt; The schemas, the API, the access enforcement and the admin console are generated from declarations; the custom screens in this app are a file I wrote by hand. I have seen "N bytes in, M bytes out" claims made about codebases like this one and I'm not going to make one.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;The live demo is at &lt;strong&gt;&lt;a href="https://sentinel.supero.live" rel="noopener noreferrer"&gt;sentinel.supero.live&lt;/a&gt;&lt;/strong&gt; — sign in as a policyholder and as the claims team on the same claim and watch the payload change.&lt;/p&gt;

&lt;p&gt;If you'd rather read the schemas and the access policy than run curl, the app is on GitHub under MIT: &lt;strong&gt;&lt;a href="https://github.com/supero-platform/supero-apps" rel="noopener noreferrer"&gt;github.com/supero-platform/supero-apps&lt;/a&gt;&lt;/strong&gt; (&lt;code&gt;apps/insurance/sentinel&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;I'm curious where people land on this. The objection I expect — and half agree with — is that moving authorization into a declarative layer trades one failure mode for another: you can't grep for it, and a misconfigured policy is as bad as a missing &lt;code&gt;if&lt;/code&gt;. If you've run something like this in production, I'd like to hear which way that went.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>showdev</category>
      <category>security</category>
      <category>saas</category>
    </item>
  </channel>
</rss>
