This document contains feature specifications using our User Story Template. All features follow our Practices and Feature Development Process.
This application is built as a Laravel monolith with a Vue 3 frontend using Inertia.js. All features are implemented within this single codebase:
- Backend: Laravel 11 (PHP 8.2+) with MySQL database
- Frontend: Vue 3 with TypeScript, Tailwind CSS v4, and shadcn-vue components
- Integration: Inertia.js seamlessly bridges Laravel and Vue (no API layer needed for most features)
- Authentication: Firebase Authentication (OAuth and email/password)
- State Management: Pinia for Vue frontend state
For detailed architecture information, see Architecture Documentation and Laravel App Architecture Decisions.
As a user, I want to create and manage multiple organizations so that I can logically separate different projects or clients, with each organization completely isolated from others.
When a user signs up, a default organization is automatically created. Users can create additional organizations, switch between them via a dropdown in the navigation, and manage organization settings. All AWS accounts, scans, and reports are scoped to the currently selected organization. Users can only see and manage organizations they own.
- Given I am a new user, when I sign up, then a default organization is automatically created for me
- Given I am logged in, when I click "Add Organization" and provide a name, then a new organization is created and available in the organization selector
- Given I have multiple organizations, when I select a different organization from the dropdown, then the UI updates to show data for that organization
- Given I am viewing an organization, when I look at the navigation, then I can see which organization is currently selected
- Given I have an organization with no AWS accounts, when I try to delete it, then the organization is deleted successfully
- Given I have an organization with AWS accounts, when I try to delete it, then I see an error preventing deletion
- Given I have only one organization, when I try to delete it, then I see an error preventing deletion of the last organization
- Given I own an organization, when I update its name, then the name is saved and reflected in the UI
- Given I try to access another user's organization, then I receive an authorization error
interface Organization {
id: string; // UUID, primary key
name: string; // User-visible name
orgId: string; // UUID, unique identifier (NOT visible in UI)
userId: string; // Owner user ID
isDefault: boolean; // True for first org created
createdAt: Date;
updatedAt: Date;
}-
Default Organization:
- Created automatically on user signup
- Named after username or "My Organization"
- Cannot be deleted if it's the only organization
-
OrgId Generation:
- Generated as UUID v4 on organization creation
- Never displayed in UI
- Used for all database queries and API calls
- Ensures organization isolation
-
Organization Switching:
- User can switch via dropdown in top navigation
- Current organization stored in Vue frontend state (Pinia)
- All subsequent API calls include OrgId in context
- UI updates to show organization-specific data
-
Deletion Rules:
- Organization can only be deleted by owner
- Cannot delete if AWS accounts are attached
- Cannot delete if it's the only organization
All API endpoints are prefixed with /api/ and require Firebase authentication and organization context middleware.
GET /api/organizations
Response: {
organizations: [
{
id: "uuid",
name: "My Organization",
orgId: "uuid-hidden",
isDefault: true,
createdAt: "2024-01-01T00:00:00Z"
}
]
}
POST /api/organizations
Body: {
name: "New Organization"
}
Response: {
id: "uuid",
name: "New Organization",
orgId: "uuid-generated",
isDefault: false,
createdAt: "2024-01-01T00:00:00Z"
}
PUT /api/organizations/{orgId}
Body: {
name: "Updated Name"
}
Response: {
id: "uuid",
name: "Updated Name",
orgId: "uuid",
updatedAt: "2024-01-01T00:00:00Z"
}
DELETE /api/organizations/{orgId}
Response: {
success: true
}
GET /api/organizations/current
Response: {
id: "uuid",
name: "My Organization",
orgId: "uuid",
isDefault: true
}
-
Organization Selector (Top Navigation):
- Dropdown showing all user's organizations
- Current organization highlighted
- "Add Organization" option at bottom
- Clicking organization switches context
-
Organization Management Page:
- List of all organizations
- Create new organization button
- Edit/Delete actions per organization
- Shows AWS account count per organization
As a User, I want to Add an AWS Account to my current Organization so that I can connect it to the cloud security app.
ExternalId Generation: ExternalId is generated for each new AWS Account creation request. The UniqueId is derived from the OrgId. This does not change once an organization is created.
Temporary Record Creation: This generates a temporary AWS Account record in the database as "pending" status.
CloudFormation URL: When a new AWS account is added, a new window / tab is opened in the browser to redirect the user to the cloudformation url.
CloudFormation Parameters: The Cloudformation URL has 3 parameters: ExternalId, UniqueId, ParentAWSAccountId.
CloudFormation URL Format:
https://console.aws.amazon.com/cloudformation/home?#/stacks/quickcreate?templateUrl={TOPS_CFN_TEMPLATE_URL}&stackName=tops-vendor-audit¶m_ParentAWSAccountId={awsAccountId}¶m_ExternalId={externalId}¶m_UniqueId={uniqueId}
CloudFormation Execution: The Cloudformation will be created by the user within their AWS account.
SNS to SQS Flow: Once the Cloudformation template has been created it will send an SNS back to the parent AWS account. This SNS then sends to an SQS Queue. The SQS Queue can be polled by the Laravel backend. (Environment Variables: TOPS_SQS_NAME and TOPS_SQS_ARN)
Database Update: Database record for new AWS account will be updated. (This process will be handled by the Laravel backend Service that can be polling the SQS Queue for changes).
Status Update: AWS Account record in table will be updated with status "completed".
Additional Behavior: Users can see all accounts for their current organization, update account names, and remove accounts they no longer need. If the automated process fails, users can manually enter account details.
- Given I am viewing an organization, when I click "Add AWS Account", then I receive a CloudFormation URL that opens in a new window
- Given I initiate account addition, when the system generates the request, then ExternalId is generated and UniqueId is derived from the organization's OrgId
- Given I have initiated account addition, when I complete the CloudFormation stack in AWS Console, then the account is automatically registered via SNSβSQS flow and status changes to "completed"
- Given I have initiated account addition, when the SQS message is processed by Laravel backend, then the IAM Role ARN is stored encrypted and account status updates to "completed"
- Given I am viewing an organization, when I view the AWS accounts list, then I see all accounts for that organization with their status
- Given I have an AWS account, when I update its name, then the name is saved and reflected in the list
- Given I have an AWS account, when I delete it, then the account is removed from the organization
- Given the automated registration fails, when I choose "Enter Manually", then I can provide AWS Account ID and IAM Role ARN to complete registration
- Given I try to add a duplicate AWS Account ID to the same organization, then I see an error preventing the duplicate
- Given I switch organizations, when I view AWS accounts, then I only see accounts for the current organization
- Given an account is in "pending" status, when I view the account, then I see clear instructions on next steps
- Given an organization is created, when I add AWS accounts to it, then all accounts use the same UniqueId (derived from OrgId)
- Task Success Rate: >90% of users successfully add AWS accounts
- Time to Complete: <5 minutes from initiation to active account (including CloudFormation)
- Error Rate: <10% of accounts fail to register (with manual fallback available)
- User Satisfaction: Account addition process is clear and straightforward
- Product Practices - Simplicity First, User Experience
- Security Practices - Data Protection (Encryption), Secrets Management, API Security
- Database Practices - Schema Design, Data Integrity
- Architecture Practices - Simplicity First, Standard Patterns
- IAM Role ARN must be encrypted at rest using AES-256
- ExternalId is generated as UUID for each AWS Account creation request
- UniqueId is derived from the organization's OrgId and does not change once an organization is created
- CloudFormation template URL is configurable via
TOPS_CFN_TEMPLATE_URLenvironment variable - CloudFormation URL includes
stackName=tops-vendor-auditparameter - SNS notification goes to parent AWS account, which forwards to SQS Queue
- SQS Queue name and ARN configured via
TOPS_SQS_NAMEandTOPS_SQS_ARNenvironment variables - Laravel backend polls SQS Queue for new messages (instead of direct webhook)
- Manual fallback allows users to complete setup if automation fails
- Account status polling in frontend checks for status updates
- Status values:
pending,completed,error(note: "completed" instead of "active")
interface AwsAccount {
id: string; // UUID, primary key
name: string; // User-defined name
awsAccountId: string; // 12-digit AWS account ID
iamRoleArn: string; // Encrypted IAM Role ARN
externalId: string; // UUID, generated per account for AssumeRole security
uniqueId: string; // Derived from organization.orgId, same for all accounts in org
organizationId: string; // Links to organization.orgId
status: 'pending' | 'completed' | 'error';
createdAt: Date;
updatedAt: Date;
lastScanAt?: Date;
}-
Account Naming:
- User can provide custom name (optional)
- Defaults to "AWS Account {awsAccountId}" if not provided
- Names are unique within an organization
-
Status Flow:
pending: CloudFormation URL generated, waiting for completioncompleted: SQS message processed, account ready for scanningerror: CloudFormation failed or notification timeout
-
Security:
- IAM Role ARN encrypted at rest (AES-256)
- ExternalId ensures only parent account can assume role (generated per account)
- UniqueId derived from OrgId, same for all accounts in an organization
- UniqueId prevents account hijacking and links accounts to organization
-
Organization Isolation:
- AWS accounts are scoped to organizations
- Cannot see accounts from other organizations
- Switching organization shows different accounts
User Action: Clicks "Add AWS Account" button
Vue Frontend:
- Gets current organization.orgId from state
- Calls POST /api/organizations/{orgId}/aws-accounts/init
Laravel Backend:
- Derives UniqueId from organization.orgId (does not change)
- Generates ExternalId (UUID) for this account
- Creates pending record in MySQL database
- Constructs CloudFormation URL with stackName parameter
- Returns URL to frontend
Vue Frontend:
- Opens CloudFormation URL in new window
- URL format:
https://console.aws.amazon.com/cloudformation/home?#/stacks/quickcreate?
templateUrl={TOPS_CFN_TEMPLATE_URL}&
stackName=tops-vendor-audit&
param_ParentAWSAccountId={PARENT_ACCOUNT_ID}&
param_ExternalId={EXTERNAL_ID}&
param_UniqueId={UNIQUE_ID}
User:
- Completes CloudFormation stack in AWS Console
- Stack creates:
* IAM Role (TeemOps) with AssumeRole permissions
* CustomNotifier that sends SNS notification
CloudFormation CustomNotifier:
- Sends SNS message to parent account
- Topic: arn:aws:sns:region:parent-account-id:teemops-sns
- Payload:
{
TopsRoleArn: "arn:aws:iam::123456789012:role/TeemOps",
TopsExternalId: "uuid-external-id",
TopsUniqueId: "org-uuid-derived-from-orgid",
TopsType: "org-uuid-here" // Optional: can encode OrgId
}
AWS SNS β SQS:
- SNS forwards message to SQS Queue
- Queue name: {TOPS_SQS_NAME}
- Queue ARN: {TOPS_SQS_ARN}
Laravel SQS Polling Service:
- Polls SQS Queue for new messages (background job/command)
- Extracts RoleArn, ExternalId, UniqueId from message
- Queries MySQL database for account with matching UniqueId and ExternalId
- Encrypts and stores IAM Role ARN using Laravel encryption
- Updates status to "completed"
- Deletes message from SQS queue after processing
Vue Frontend:
- Polls GET /api/aws-accounts/{accountId} for status
- Shows "Pending" while status is pending
- Shows "Completed" when status becomes completed
- Shows error message if status becomes error
POST /api/organizations/{orgId}/aws-accounts/init
Response: {
cloudFormationUrl: "https://console.aws.amazon.com/cloudformation/...",
accountId: "uuid-pending-record-id",
uniqueId: "uuid-unique-id",
status: "pending"
}
GET /api/organizations/{orgId}/aws-accounts
Response: {
accounts: [
{
id: "uuid",
name: "Production AWS",
awsAccountId: "123456789012",
status: "active",
lastScanAt: "2024-01-01T00:00:00Z"
}
]
}
GET /api/aws-accounts/{accountId}
Response: {
id: "uuid",
name: "Production AWS",
awsAccountId: "123456789012",
status: "active",
createdAt: "2024-01-01T00:00:00Z",
lastScanAt: "2024-01-01T00:00:00Z"
}
PUT /api/aws-accounts/{accountId}
Body: {
name: "Updated Name"
}
Response: {
id: "uuid",
name: "Updated Name",
updatedAt: "2024-01-01T00:00:00Z"
}
DELETE /api/aws-accounts/{accountId}
Response: {
success: true
}
Laravel Command/Job:
- Polls SQS Queue: {TOPS_SQS_NAME}
- Processes messages from queue
- Extracts account information from SNS message
- Updates database record
- Deletes message from queue after successful processing
SQS Message Format (from SNS):
{
"Type": "Notification",
"Message": {
"TopsRoleArn": "arn:aws:iam::123456789012:role/TeemOps",
"TopsExternalId": "uuid-external-id",
"TopsUniqueId": "org-uuid-derived-from-orgid",
"TopsType": "org-uuid-here"
}
}
If SNS notification fails or times out:
- User clicks "Enter Manually" button
- Vue frontend shows form:
- AWS Account ID (12 digits)
- IAM Role ARN (from CloudFormation outputs)
- Laravel backend validates:
- AWS Account ID format
- IAM Role ARN format
- Role exists and is assumable
- Laravel stores account in MySQL and marks as "active"
-
AWS Accounts List Page:
- Shows all accounts for current organization
- Status badges (Pending, Active, Error)
- Last scan timestamp
- Actions: View, Scan, Remove
-
Add AWS Account Flow:
- Step 1: Shows CloudFormation instructions
- "Open AWS Console" button
- "Enter Manually" fallback option
- Step 2: Manual entry form (if needed)
- Status polling indicator
-
Account Status Indicators:
- π‘ Pending: Waiting for CloudFormation
- π’ Active: Ready for scanning
- π΄ Error: Setup failed
-
CloudFormation Timeout:
- After 5 minutes, show "Enter Manually" option
- Allow user to complete setup manually
-
SNS Notification Failure:
- Log error for debugging
- Allow manual entry
- Show error message to user
-
Duplicate Account:
- Prevent adding same AWS Account ID twice in same organization
- Show error: "This AWS account is already connected"
-
Invalid Role ARN:
- Validate format on Laravel backend
- Test AssumeRole capability using AWS SDK
- Show specific error message
-
Encryption:
- IAM Role ARN encrypted at rest
- ExternalId and UniqueId stored as plain UUIDs (not sensitive)
-
AssumeRole Security:
- ExternalId condition ensures only parent account can assume
- IAM role has least-privilege permissions
- Role can be revoked by customer at any time
-
SNS Verification:
- Verify SNS message signature
- Validate message source (parent account SNS topic)
- Rate limit callback endpoint
-
Data Isolation:
- All queries filtered by organization.orgId
- Users cannot access accounts from other organizations
- API validates organization membership
As a user, when I sign up and receive a verification email, I need to see a message at the top of the screen confirming the email was sent. When I verify my email, I need to see a success message at the top of the screen. When I sign up via OAuth, I need to see a welcome message.
When users interact with the authentication system, they receive clear, timely feedback through in-app notifications displayed at the top right of the screen. These notifications provide confirmation of actions taken and guide users through the signup and verification process.
Registration Flow (Email/Password):
- User completes registration form
- System creates account and sends verification email
- User sees info notification: "Registration successful! Please check your email to verify your account."
- User is redirected to email verification page
- User clicks verification link in email
- User sees success notification: "Your email has been verified successfully!"
- User is redirected to dashboard
Email Verification Resend:
- User clicks "Resend Verification Email" button
- System sends new verification email
- User sees info notification: "Verification email sent! Please check your inbox."
OAuth Signup Flow:
- User clicks OAuth provider button (Google, GitHub, Microsoft)
- User completes OAuth authorization
- System creates account and automatically verifies email
- User sees success notification: "Successfully logged in via [Provider]!"
- User is redirected to dashboard (no verification required)
- Given I am a new user, when I register with email and password, then I see an info notification confirming registration and email verification instructions
- Given I have registered, when I receive the verification email and click the link, then I see a success notification confirming my email is verified
- Given I am on the email verification page, when I click "Resend Verification Email", then I see an info notification confirming the email was sent
- Given I sign up via OAuth (Google, GitHub, or Microsoft), when the OAuth flow completes, then I see a success notification welcoming me and confirming login
- Given I am already verified, when I try to verify my email again, then I see an info notification that my email is already verified
- Given I see a notification, when the notification appears, then it automatically dismisses after 5-7 seconds
- Given I see a notification, when I click the dismiss button, then the notification is immediately removed
- Given multiple notifications appear, when they stack, then they are displayed vertically without overlapping
- Use for: Successful operations, confirmations, OAuth signups
- Auto-dismiss: 5 seconds
- Examples:
- "Your email has been verified successfully!"
- "Successfully logged in via Google!"
- "Account created and verified!"
- Use for: Informational messages, instructions
- Auto-dismiss: 5 seconds
- Examples:
- "Registration successful! Please check your email to verify your account."
- "Verification email sent! Please check your inbox."
- "Your email is already verified."
- Use for: Warnings, cautions
- Auto-dismiss: 6 seconds
- Examples:
- "Your session will expire soon."
- "Please review your settings."
- Use for: Errors, failures
- Auto-dismiss: 7 seconds (longer for errors)
- Examples:
- "Authentication failed. Please try again."
- "Failed to send verification email."
Notifications are ephemeral and do not require a database model. They are managed in the frontend using Vue composables and displayed via Vue components.
interface Notification {
id: string; // UUID for tracking
message: string; // Notification text
type: 'success' | 'error' | 'warning' | 'info';
timeout?: number; // Milliseconds before auto-dismiss (0 = no auto-dismiss)
}-
Notification Display:
- Notifications appear at top right of screen
- Maximum width: 384px (max-w-sm)
- Stack vertically with spacing
- Slide in from right with animation
- Slide out to right when dismissed
-
Auto-Dismiss:
- Success: 5 seconds
- Info: 5 seconds
- Warning: 6 seconds
- Error: 7 seconds
- Can be disabled by setting timeout to 0
-
Manual Dismiss:
- All notifications have a dismiss button (X)
- Clicking dismiss immediately removes notification
- Dismiss button is accessible and keyboard navigable
-
Flash Message Integration:
- Laravel flash messages are automatically converted to notifications
- Flash messages are cleared after being displayed
- Supports:
success,error,warning,info
-
Dark Mode:
- Notifications support both light and dark themes
- Colors adapt based on system/user preference
Notifications are primarily frontend-driven, but Laravel controllers set flash messages that are converted to notifications:
POST /register
Response: Redirect to /verify-email
Flash Message: info - "Registration successful! Please check your email to verify your account."
GET /verify-email/{id}/{hash}
Response: Redirect to /dashboard?verified=1
Flash Message: success - "Your email has been verified successfully!"
POST /email/verification-notification
Response: Redirect back
Flash Message: success - "A new verification link has been sent to your email address!"
POST /auth/firebase/verify
Response: Redirect to /dashboard
Flash Message: success - "Successfully logged in via [Provider]!"
-
Notification Component (
Notification.vue):- Individual notification card
- Type-specific styling (colors, icons)
- Dismiss button
- Slide-in animation
- Accessible (ARIA labels, keyboard navigation)
-
Notification Container (
NotificationContainer.vue):- Fixed position container (top right)
- Manages multiple notifications
- Watches for Laravel flash messages
- Transition group for animations
- Clears flash messages after display
-
Notification Composable (
useNotifications.ts):- Global notification state
- Helper functions:
showSuccess(),showError(),showWarning(),showInfo() - Auto-dismiss timers
- Notification management (add, dismiss)
-
Laravel Middleware (
HandleInertiaRequests):- Shares flash messages with Inertia frontend
- Provides:
flash.success,flash.error,flash.warning,flash.info
-
Vue Layouts:
SidebarAppLayout.vue: Includes<NotificationContainer />for app pagesSplitAuthLayout.vue: Includes<NotificationContainer />for auth pages
-
Laravel Controllers:
RegisteredUserController: Sets info flash on registrationVerifyEmailController: Sets success flash on verificationEmailVerificationNotificationController: Sets success flash on resendFirebaseAuthController: Sets success flash on OAuth login
-
Missing Flash Messages:
- If flash message is null/undefined, notification is not shown
- No errors thrown for missing flash messages
-
Notification Overflow:
- Notifications stack vertically
- Older notifications are pushed down
- Maximum visible notifications: ~5-6 (depends on screen height)
-
Animation Failures:
- Notifications still display even if animations fail
- Graceful degradation for older browsers
-
XSS Prevention:
- Notification messages are sanitized by Laravel
- Vue automatically escapes content in templates
- No user input directly in notification messages
-
Flash Message Security:
- Flash messages are stored in Laravel session
- Session is encrypted and secure
- Flash messages cleared after display
- β
Notification component (
Notification.vue) - β
Notification container (
NotificationContainer.vue) - β
Notification composable (
useNotifications.ts) - β
Integration with
SidebarAppLayout - β
Integration with
SplitAuthLayout - β Flash message sharing via middleware
- β Registration flow messages
- β Email verification messages
- β OAuth signup messages
- β
Test page (
/test-notifications) - β Status: Complete
- Product Practices - User Experience, Simplicity First
- Design Practices - UX principles, component design, accessibility
- Security Practices - XSS Prevention
- Location: S3 bucket in parent account
- Parameters: ParentAWSAccountId, ExternalId, UniqueId
- Outputs: IAM Role ARN
- Custom Resource: SNS notification sender
- SNS Topic Name:
teemops-sns(in parent AWS account) - SQS Queue Name: Configured via
TOPS_SQS_NAMEenvironment variable - SQS Queue ARN: Configured via
TOPS_SQS_ARNenvironment variable - Flow: CloudFormation β SNS β SQS β Laravel polling service
- Message Format: JSON with RoleArn, ExternalId, UniqueId, Type
- Init Endpoint: Derives UniqueId from orgId, generates ExternalId, creates pending record, returns CF URL
- SQS Polling Service: Background command (
php artisan aws:process-sqs) that polls SQS queue, processes messages, updates account status to "completed" - Account Management: CRUD operations for AWS accounts
The Laravel application includes a command to poll the SQS queue for AWS account registration messages:
# Run once (process messages and exit)
php artisan aws:process-sqs --once
# Run continuously (long-running process)
php artisan aws:process-sqsRecommended Setup: Run as a supervisor/systemd service or schedule via Laravel scheduler:
// In app/Console/Kernel.php
$schedule->command('aws:process-sqs --once')->everyMinute();Environment Variables Required:
TOPS_SQS_NAME: Name of the SQS queueTOPS_SQS_ARN: ARN of the SQS queueAWS_DEFAULT_REGION: AWS region (defaults to us-east-1)
- β Laravel Backend: CRUD operations implemented
- β Vue Frontend: UI and state management implemented
- β MySQL Database: Schema complete
- β Status: Complete
- β MySQL Database: Schema complete (status enum: pending, completed, error)
- β Vue Frontend: UI structure and state management implemented
- β Laravel Backend: CloudFormation URL generation with stackName parameter
- β Laravel Backend: UniqueId derived from organization.orgId
- β Laravel Backend: ExternalId generated per account
- β Laravel Backend: IAM Role ARN encryption implemented
- β Vue Frontend: Status polling for pending accounts
- β Vue Frontend: Manual fallback UI implemented
β οΈ Laravel Backend: SQS Queue polling service - Not Started (needs background job/command)β οΈ Status: Partial - Core functionality complete, SQS polling service needed
- All features follow our Practices documents
- Features are developed incrementally following Feature Development Process
- See PROGRESS.md for detailed implementation status