Atria - User Model and Tagging System
UserTypes
Cognito Attribute “profile” UserTypes |
|---|
Monitor |
Installer |
Surveyor |
Provisioner |
Admin |
User Data
In Cognito
custom:company | profile | sub | |
|---|---|---|---|
Company Name | User’s Email Address | UserType | Cognito User ID |
TagsTable Dynamo DB
UserId | Tags |
|---|---|
Cognito User ID | List of Tags that we can associate with a Site, Dashboard, or group of Sites. |
This table would need a GSI ^
Tags
This will be something that the Admin UserTypes (and above) can add Tags onto the Installer and Monitor types.
This will enable us to restrict access from the Site Search, as well as restrict installers to install to Sites they are approved, and only see data for Sites in general for which they are approved.
Dashboards should have a unique Id that is stored in a list here, this way we can restrict who has access to dashboards.
Entities will have basically hashtags, that associate them with certain users or multiple users.
Example: a Site has #spain tag would mean that an installer in Spain who has this SiteTag can see this Site.
DynamoDB Table Definition
Table name: UserTags
Attribute | Type | Description |
|---|---|---|
Email (PK) | String | The Cognito user’s email address (primary key). |
Tags | String list / set | List of normalized tags, e.g. |
UpdatedAt (optional) | Number (epoch) | Timestamp of the last update, e.g. |
Example Items
User who should see the Water Mains dashboard:
{
"Email": "alice@example.com",
"Tags": ["water-mains", "spain"],
"UpdatedAt": 1730836800
}
User who should NOT see it:
{
"Email": "bob@example.com",
"Tags": ["spain"],
"UpdatedAt": 1730836800
}
Frontend Integration
Assumptions
The user signs in via Amazon Cognito, and their email is available in the ID token’s
emailclaim.The backend exposes an endpoint at
/me/tags(via API Gateway + Lambda or a custom server) that returns the caller’s tag list.
React Example
import { useEffect, useState } from "react";
import WaterMains from "./waterMains.jsx";
export default function DashHome() {
const [tags, setTags] = useState(null);
useEffect(() => {
(async () => {
// Include the Cognito ID token in Authorization header if required
const res = await fetch("/me/tags", {
headers: { Authorization: /* idToken */ "" },
});
const data = await res.json(); // e.g. { tags: ["water-mains", "spain"] }
setTags(data.tags ?? []);
})();
}, []);
if (tags === null) return null; // or render a loading skeleton
const canSeeWaterMains = tags.includes("water-mains");
return (
<>
{canSeeWaterMains && <WaterMains />}
{/* If using routes, also guard them: */}
{/* <Route path="/dash/water" element={canSeeWaterMains ? <WaterMains/> : <NotFound/>} /> */}
</>
);
}
Backend (Lambda Handler)
Purpose
Fetch the signed-in user’s tags from DynamoDB and return them.
Implementation
import json
import boto3
dynamodb = boto3.resource("dynamodb")
table = dynamodb.Table("UserTags")
def handler(event, context):
# Extract claims from request context
claims = (
event.get("requestContext", {})
.get("authorizer", {})
.get("jwt", {})
.get("claims", {})
)
email = claims.get("email")
if not email:
return {
"statusCode": 401,
"body": json.dumps({"error": "Missing email claim"})
}
try:
# Fetch user tags from DynamoDB
response = table.get_item(Key={"Email": email})
item = response.get("Item", {})
tags = item.get("Tags", [])
return {
"statusCode": 200,
"headers": {"Content-Type": "application/json"},
"body": json.dumps({"tags": tags})
}
except Exception as e:
print(f"Error: {e}")
return {
"statusCode": 500,
"body": json.dumps({"error": "Internal Server Error"})
}
Admin Tag Management
Admins can create or update user tag lists using a dedicated admin Lambda.
import json
import os
import boto3
dynamodb = boto3.resource("dynamodb")
table = dynamodb.Table(os.environ.get("USER_TAGS_TABLE", "UserTags"))
def _is_admin(claims: dict) -> bool:
groups = claims.get("cognito:groups", []) or claims.get("groups", [])
if isinstance(groups, str):
groups = [groups]
profile = claims.get("profile")
return ("Admin" in groups) or (str(profile).lower() == "admin")
def _json(body):
try:
return json.loads(body or "{}")
except Exception:
return {}
def _normalize_tags(tags):
"""Normalize tags: lowercase, strip whitespace, deduplicate."""
seen, out = set(), []
for t in tags or []:
if not isinstance(t, str):
continue
n = t.strip().lower()
if n and n not in seen:
seen.add(n)
out.append(n)
return out
def handler(event, context):
# Authorization: ensure caller is Admin
claims = (
event.get("requestContext", {})
.get("authorizer", {})
.get("jwt", {})
.get("claims", {})
)
if not _is_admin(claims):
return {"statusCode": 403, "body": json.dumps({"error": "Forbidden"})}
method = (
event.get("requestContext", {}).get("http", {}).get("method")
or event.get("httpMethod")
)
payload = _json(event.get("body"))
email = payload.get("email")
if not email or not isinstance(email, str):
return {"statusCode": 400, "body": json.dumps({"error": "Missing or invalid 'email'"})}
# Basic CORS headers (adjust origin as needed)
cors = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Headers": "authorization,content-type",
"Access-Control-Allow-Methods": "PUT,PATCH,OPTIONS"
}
if method == "OPTIONS":
return {"statusCode": 200, "headers": cors, "body": ""}
try:
if method == "PUT":
# Replace the full tag list
tags = _normalize_tags(payload.get("tags", []))
table.put_item(Item={"Email": email, "Tags": tags})
return {"statusCode": 200, "headers": cors, "body": json.dumps({"ok": True, "tags": tags})}
elif method == "PATCH":
# Append or update specific tags
tags = _normalize_tags(payload.get("tags", []))
table.update_item(
Key={"Email": email},
UpdateExpression="SET Tags = list_append(if_not_exists(Tags, :empty), :tags)",
ExpressionAttributeValues={":tags": tags, ":empty": []}
)
return {"statusCode": 200, "headers": cors, "body": json.dumps({"ok": True, "added": tags})}
else:
return {"statusCode": 405, "body": json.dumps({"error": "Unsupported method"})}
except Exception as e:
print(f"Error: {e}")
return {"statusCode": 500, "body": json.dumps({"error": "Internal Server Error"})}
IAM Permissions
Function | Required Permissions |
|---|---|
/me/tags Lambda |
|
Admin update Lambda |
|
Notes & Best Practices
Client-only checks: UI-level checks (like
tags.includes("water-mains")) are suitable for conditional rendering.
However, server-side validation is required for API access control.Email as primary key: Ensure Cognito emails are unique and verified.
If users can change their email, either:Copy the record to the new email key and delete the old one, or
Store an immutable Cognito
subas aUserIdattribute for reference.
Tag normalization:
Always lowercase and hyphenate tags (e.g.,water-mains) to prevent mismatches.
Quick Summary
Table:
UserTagskeyed by email.Each user has a list of tags defining what dashboards/features they can access.
Frontend uses
/me/tagsto conditionally render UI.Admins manage tags via a secure Lambda endpoint.
Server-side enforcement is required for sensitive data.
Example conditional render:
tags.includes("water-mains") ? <WaterMains /> : null