Atria - User Model and Tagging System

Atria - User Model and Tagging System

UserTypes

Cognito Attribute “profile” UserTypes

Cognito Attribute “profile” UserTypes

Monitor

Installer

Surveyor

Provisioner

Admin

User Data

In Cognito

custom:company

email

profile

sub

custom:company

email

profile

sub

Company Name

User’s Email Address

UserType

Cognito User ID

TagsTable Dynamo DB

UserId

Tags

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

Attribute

Type

Description

Email (PK)

String

The Cognito user’s email address (primary key).

Tags

String list / set

List of normalized tags, e.g. ["water-mains", "spain"].

UpdatedAt (optional)

Number (epoch)

Timestamp of the last update, e.g. 1730836800.

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 email claim.

  • 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

Function

Required Permissions

/me/tags Lambda

dynamodb:GetItem on the UserTags table

Admin update Lambda

dynamodb:UpdateItem (or PutItem) on the UserTags table


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 sub as a UserId attribute for reference.

  • Tag normalization:
    Always lowercase and hyphenate tags (e.g., water-mains) to prevent mismatches.


Quick Summary

  • Table: UserTags keyed by email.

  • Each user has a list of tags defining what dashboards/features they can access.

  • Frontend uses /me/tags to 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