Demo Mode (Read-Only Public Access)
Overview
Smart Supply Pro supports a read-only demo mode for public showcases, product demonstrations, and evaluation. Demo mode:
- Disables all write operations (POST, PUT, DELETE)
- Allows anonymous GET access to inventory data
- Overrides role-based access control (RBAC)
- Useful for live product demos and trial accounts
- Can be toggled via environment variable
Demo Mode Configuration
Enable/Disable Demo Mode
Environment Variable:
APP_DEMO_READONLY=true # Enable read-only mode
APP_DEMO_READONLY=false # Disable (default)Application Properties:
# application.yml (shared)
app:
demo-readonly: ${APP_DEMO_READONLY:false}Fly.io Production (fly.toml):
[env]
SPRING_PROFILES_ACTIVE = "prod"
APP_DEMO_READONLY = "true" # Demo enabled in productionDocker Runtime:
docker run \
-e SPRING_PROFILES_ACTIVE=prod \
-e APP_DEMO_READONLY=true \
-p 8081:8081 \
inventory-service:latestKubernetes:
env:
- name: APP_DEMO_READONLY
value: "true"Authorization Override
How Authorization Changes
Normal Mode (demo-readonly=false):
User Role (ADMIN/USER) β Request Authorization β HTTP 403 if unauthorized
Demo Mode (demo-readonly=true):
GET Request? β Allow (all users, anonymous)
β
Write Request? β Deny with "Demo Mode" message
β
HTTP 403 (Forbidden)
Implementation
Demo mode is enforced at BOTH URL level AND method level.
SecurityAuthorizationHelper.java supports demo mode conditionally:
public void configureAuthorization(
AuthorizeHttpRequestsConfigurer<HttpSecurity>.AuthorizationManagerRequestMatcherRegistry auth,
boolean isDemoReadonly) {
// Demo mode: allow read-only endpoints without login
if (isDemoReadonly) {
auth.requestMatchers(HttpMethod.GET, "/api/inventory/**").permitAll();
auth.requestMatchers(HttpMethod.GET, "/api/analytics/**").permitAll();
auth.requestMatchers(HttpMethod.GET, "/api/suppliers/**").permitAll();
}
// Default: authenticated users may READ these resources
auth.requestMatchers(HttpMethod.GET, "/api/inventory/**").authenticated();
auth.requestMatchers(HttpMethod.GET, "/api/suppliers/**").authenticated();
auth.requestMatchers(HttpMethod.GET, "/api/analytics/**").authenticated();
// Write operations ALWAYS require USER or ADMIN role (demo doesn't affect URL rules)
auth.requestMatchers(HttpMethod.POST, "/api/inventory/**").hasAnyRole("USER", "ADMIN");
auth.requestMatchers(HttpMethod.PUT, "/api/inventory/**").hasAnyRole("USER", "ADMIN");
auth.requestMatchers(HttpMethod.PATCH, "/api/inventory/**").hasAnyRole("USER", "ADMIN");
auth.requestMatchers(HttpMethod.DELETE, "/api/inventory/**").hasAnyRole("USER", "ADMIN");
}InventoryItemController.java applies method-level checks:
// Read operations: authenticated users OR demo mode (unauthenticated)
@GetMapping
@PreAuthorize("isAuthenticated() or @appProperties.demoReadonly")
public List<InventoryItemDTO> getAll() { ... }
// Write operations: ALWAYS blocked if in demo mode
@PostMapping
@PreAuthorize("hasRole('ADMIN') and !@securityService.isDemo()")
public ResponseEntity<InventoryItemDTO> create(@RequestBody InventoryItemDTO dto) { ... }
@PatchMapping("/{id}/quantity")
@PreAuthorize("hasAnyRole('USER','ADMIN') and !@securityService.isDemo()")
public InventoryItemDTO adjustQuantity(@PathVariable String id,
@RequestParam int delta,
@RequestParam StockChangeReason reason) {
// Demo users cannot adjust quantities (403 Forbidden)
}Why this two-level approach?
- β URL-level: Determines if unauthenticated GET access is allowed
- β Method-level: Ensures demo users cannot write even if somehow authenticated
- β Redundant safety: Both layers block writes in demo mode
- β Clear intent: Each layer serves a specific purpose
Demo Mode Authorization Flow
User Request
β
βββββββββββββββββββββββββββββββββββ
β Is it a GET request? β
βββββββββββββββββββββββββββββββββββ
β YES β NO (Write: POST/PUT/PATCH/DELETE)
β β
βββββββββββββββββββββββββββββββββββ ββββββββββββββββββββββββββββββββ
β URL-Level Check β β Check User Has Role β
β isDemoReadonly=true? β β (USER or ADMIN) β
βββββββββββββββββββββββββββββββββββ ββββββββββββββββββββββββββββββββ
β YES β NO β NO β YES
[permitAll] [authenticated] [403] [Check Demo Mode]
β β β
[Method Check] [Method Check] ββββββββββββββββββββββββββββ
β β β isDemo() = true? β
ββββββββββββββββββββββββββββββββββββ ββββββββββββββββββββββββββββ
β @PreAuthorize: β β YES β NO
β isAuthenticated() or β [403] [Allow Write]
β @appProperties.demoReadonly β
ββββββββββββββββββββββββββββββββββββ
β YES β NO
[Return Data] [403]
Demo Mode Use Cases
1. Live Product Demonstration
Scenario: Sales team demos app at conference
# Start app in demo mode
APP_DEMO_READONLY=true ./start.sh
# Attendees see live inventory data
# Click "Add Item" β 403 Forbidden
# Message: "This feature is disabled in Demo Mode"Benefits: - β Show read-only data without risk - β No accidental modifications - β Emphasize full edit features in paid version
2. Trial/Evaluation Accounts
Scenario: Prospective customer evaluates app for 30 days
# For trial tenant (multi-tenant setup)
tenants:
trial-acme:
demo-readonly: true # Trial account
expires: 2024-12-31
licensed-acme:
demo-readonly: false # Paying customerFlow: 1. Trial user logs in with SSO 2. Can read inventory, view analytics 3. Cannot create/modify data 4. After 30 days, either upgrade or access revoked
3. Public Showcase/Tutorial
Scenario: Public website with embedded read-only dashboard
# Publicly accessible instance
APP_DEMO_READONLY=true
SPRING_SECURITY_OAUTH2_LOGIN_CLIENT_ID=public-demo-clientUse Case: - Public inventory viewer (like retail product catalog) - No login required for GET requests - Everyone sees same read-only data
4. Testing/QA Environment
Scenario: QA team testing without needing test data cleanup
# QA environment
APP_DEMO_READONLY=true SPRING_PROFILES_ACTIVE=qa
# QA can:
# - Run read tests against production-like data
# - Verify UI with real data
# - Ensure no test data pollutes productionError Messages in Demo Mode
403 Forbidden Response
Request:
POST /api/inventory \
-H "Content-Type: application/json" \
-d '{"itemName": "New Item", "quantity": 100}'Response (Demo Mode):
HTTP/1.1 403 Forbidden
Content-Type: application/json
{
"timestamp": "2024-01-15T10:30:00Z",
"status": 403,
"error": "Forbidden",
"message": "This operation is disabled in Demo Mode. Read-only access only.",
"path": "/api/inventory"
}Response (Normal Mode, User lacks ADMIN):
HTTP/1.1 403 Forbidden
Content-Type: application/json
{
"timestamp": "2024-01-15T10:30:00Z",
"status": 403,
"error": "Access Denied",
"message": "Only ADMIN users can create inventory items.",
"path": "/api/inventory"
}Distinguishing Error Causes
UI Logic (Frontend):
// frontend/src/api.ts
async function makeRequest(method: string, url: string, data?: unknown) {
const response = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: data ? JSON.stringify(data) : undefined
});
if (response.status === 403) {
const error = await response.json();
// Check if demo mode or insufficient permissions
if (error.message.includes('Demo Mode')) {
// Show "Demo Mode" notification
showToast("Demo Mode: Read-only access", "info");
} else if (error.message.includes('ADMIN')) {
// Show "Permission denied" notification
showToast("You need ADMIN role", "error");
}
throw new Error(error.message);
}
return response.json();
}Testing Demo Mode
Demo mode is enforced at multiple layers, so tests are split accordingly:
- Unit:
SecurityServiceTestcoversSecurityService#isDemo()principal/attribute handling (used by@PreAuthorize). - Integration: Security filter chain +
controller behavior (read allowed, writes blocked) is validated
by the
security/integration tests.
Unit Test Example
@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
class InventoryDemoModeTest {
@Autowired
private TestRestTemplate restTemplate;
@Test
@TestPropertySource(properties = "app.demo-readonly=true")
void testDemoModeBlocksCreate() {
// Given: Demo mode enabled
InventoryItemRequest request = new InventoryItemRequest();
request.setItemName("Test Item");
request.setQuantity(100);
// When: Try to POST
ResponseEntity<ErrorResponse> response = restTemplate.postForEntity(
"/api/inventory",
request,
ErrorResponse.class
);
// Then: 403 Forbidden
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN);
assertThat(response.getBody().getMessage()).contains("Demo Mode");
}
@Test
@TestPropertySource(properties = "app.demo-readonly=true")
void testDemoModeAllowsRead() {
// When: GET request
ResponseEntity<List<InventoryItemDto>> response = restTemplate.getForEntity(
"/api/inventory",
new ParameterizedTypeReference<List<InventoryItemDto>>() {}
);
// Then: 200 OK
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(response.getBody()).isNotEmpty();
}
@Test
@TestPropertySource(properties = "app.demo-readonly=false")
void testNormalModeAllowsCreate() {
// Given: Demo mode disabled
String token = obtainAdminToken();
InventoryItemRequest request = new InventoryItemRequest();
request.setItemName("Test Item");
request.setQuantity(100);
// When: POST with admin token
ResponseEntity<InventoryItemDto> response = restTemplate
.withBasicAuth("admin", "password")
.postForEntity("/api/inventory", request, InventoryItemDto.class);
// Then: 201 Created
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED);
}
}Integration Test Scenarios
@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
class DemoModeIntegrationTest {
@Test
void testFullDemoModeFlow() {
// 1. GET inventory (allowed)
List<InventoryItem> items = getInventory();
assertThat(items).isNotEmpty();
// 2. Try POST (blocked)
InventoryItemRequest newItem = new InventoryItemRequest();
ResponseEntity<ErrorResponse> postResponse = createInventory(newItem);
assertThat(postResponse.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN);
// 3. Try PUT (blocked)
ResponseEntity<ErrorResponse> putResponse = updateInventory(items.get(0).getId(), newItem);
assertThat(putResponse.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN);
// 4. Try DELETE (blocked)
ResponseEntity<ErrorResponse> deleteResponse = deleteInventory(items.get(0).getId());
assertThat(deleteResponse.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN);
}
}Security Considerations for Demo Mode
1. Data Exposure
Risk: Demo mode shows all inventory data publicly
Mitigation:
// Option A: Mask sensitive data
@GetMapping("/api/inventory")
public ResponseEntity<List<InventoryItemDto>> listInventory() {
List<InventoryItem> items = inventoryService.getAll();
return ResponseEntity.ok(
items.stream()
.map(item -> {
InventoryItemDto dto = mapper.toDto(item);
if (demoReadonly) {
dto.setCost(0); // Hide cost in demo
dto.setSupplier(null); // Hide supplier
}
return dto;
})
.collect(Collectors.toList())
);
}
// Option B: Separate demo dataset
@GetMapping("/api/demo-inventory")
public ResponseEntity<List<InventoryItemDto>> listDemoInventory() {
// Return only public demo items (pre-selected subset)
return ResponseEntity.ok(demoInventoryService.getPublicItems());
}2. Rate Limiting
Risk: Demo instance could be abused for traffic
Mitigation:
# application.yml
spring:
data:
redis:
timeout: 60000
server:
servlet:
session:
timeout: 30m # Shorter session in demo
# Rate limiting (custom)
app:
rate-limit:
enabled: true
demo-only: true # Rate limit only in demo mode
requests-per-minute: 603. Resource Limits
Risk: Demo instance shared with public, high load possible
Mitigation:
# fly.toml
[vm]
size = "shared-cpu-2x" # More resources for public demo
memory = 2048 # 2GB for demo instance
[[metrics]]
port = 9090
path = "/metrics" # Monitor resource usage4. Audit Logging
Risk: Cannot track modifications (because all writes blocked)
Benefit: Simplified logging
# Audit logs still created for GET requests
logging:
level:
com.inventoryservice.security: DEBUGLog Entry (Demo Mode):
2024-01-15T10:30:00.123 DEBUG - User: anonymous, IP: 192.168.1.5, Method: GET, Path: /api/inventory, DemoMode: true
2024-01-15T10:30:05.456 DEBUG - User: anonymous, IP: 192.168.1.5, Method: POST, Path: /api/inventory, Status: 403, Reason: Demo Mode
Toggling Demo Mode in Production
Safe Approach
# 1. Check current value
flyctl secrets get APP_DEMO_READONLY
# 2. Update (triggers rolling restart)
flyctl secrets set APP_DEMO_READONLY=false
# 3. Verify new instances started
flyctl status
# 4. Monitor health
flyctl logsGradual Rollout
# 1. Start with small percentage in demo mode
# Use canary deployment or A/B testing
# 2. Monitor error rates and performance
# 3. If good, enable for all
flyctl secrets set APP_DEMO_READONLY=falseRelated Documentation
- Security Index - Master security overview
- Authorization & RBAC - Role-based access control
- Docker Security - Container hardening