690401:1326 fix secutities uuid
CI / CD Pipeline / build (push) Successful in 28m24s
CI / CD Pipeline / deploy (push) Failing after 16m23s

This commit is contained in:
2026-04-01 13:26:19 +07:00
parent 83b04773f7
commit 1d868d10b3
17 changed files with 105 additions and 185 deletions
+1 -1
View File
@@ -12,5 +12,5 @@ services:
# Override สำหรับ Database (Local Dev) # Override สำหรับ Database (Local Dev)
mariadb: mariadb:
environment: environment:
- MYSQL_ROOT_PASSWORD=Center#2025 - MYSQL_ROOT_PASSWORD=Center2025
- MYSQL_PASSWORD=Center2025 - MYSQL_PASSWORD=Center2025
@@ -1,105 +0,0 @@
-- Migration: Align Schema with Documentation
-- Version: 1733800000000
-- Date: 2025-12-10
-- Description: Add missing fields and fix column lengths to match schema v1.5.1
-- ==========================================================
-- Phase 1: Organizations Table Updates
-- ==========================================================
-- Add role_id column to organizations
ALTER TABLE organizations
ADD COLUMN role_id INT NULL COMMENT 'Reference to organization_roles table';
-- Add foreign key constraint
ALTER TABLE organizations
ADD CONSTRAINT fk_organizations_role FOREIGN KEY (role_id) REFERENCES organization_roles(id) ON DELETE
SET NULL;
-- Modify organization_name length from 200 to 255
ALTER TABLE organizations
MODIFY COLUMN organization_name VARCHAR(255) NOT NULL COMMENT 'Organization name';
-- ==========================================================
-- Phase 2: Users Table Updates (Security Fields)
-- ==========================================================
-- Add failed_attempts for login tracking
ALTER TABLE users
ADD COLUMN failed_attempts INT DEFAULT 0 COMMENT 'Number of failed login attempts';
-- Add locked_until for account lockout mechanism
ALTER TABLE users
ADD COLUMN locked_until DATETIME NULL COMMENT 'Account locked until this timestamp';
-- Add last_login_at for audit trail
ALTER TABLE users
ADD COLUMN last_login_at TIMESTAMP NULL COMMENT 'Last successful login timestamp';
-- ==========================================================
-- Phase 3: Roles Table Updates
-- ==========================================================
-- Modify role_name length from 50 to 100
ALTER TABLE roles
MODIFY COLUMN role_name VARCHAR(100) NOT NULL COMMENT 'Role name';
-- ==========================================================
-- Verification Queries
-- ==========================================================
-- Verify organizations table structure
SELECT COLUMN_NAME,
DATA_TYPE,
CHARACTER_MAXIMUM_LENGTH,
IS_NULLABLE,
COLUMN_COMMENT
FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'organizations'
ORDER BY ORDINAL_POSITION;
-- Verify users table has new security fields
SELECT COLUMN_NAME,
DATA_TYPE,
COLUMN_DEFAULT,
IS_NULLABLE,
COLUMN_COMMENT
FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'users'
AND COLUMN_NAME IN (
'failed_attempts',
'locked_until',
'last_login_at'
)
ORDER BY ORDINAL_POSITION;
-- Verify roles table role_name length
SELECT COLUMN_NAME,
DATA_TYPE,
CHARACTER_MAXIMUM_LENGTH
FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'roles'
AND COLUMN_NAME = 'role_name';
-- ==========================================================
-- Rollback Script (Use if needed)
-- ==========================================================
/*
-- Rollback Phase 3: Roles
ALTER TABLE roles
MODIFY COLUMN role_name VARCHAR(50) NOT NULL;
-- Rollback Phase 2: Users
ALTER TABLE users
DROP COLUMN last_login_at,
DROP COLUMN locked_until,
DROP COLUMN failed_attempts;
-- Rollback Phase 1: Organizations
ALTER TABLE organizations
MODIFY COLUMN organization_name VARCHAR(200) NOT NULL;
ALTER TABLE organizations
DROP FOREIGN KEY fk_organizations_role;
ALTER TABLE organizations
DROP COLUMN role_id;
*/
+1 -1
View File
@@ -57,7 +57,7 @@ import { MigrationModule } from './modules/migration/migration.module';
// 1. Setup Config Module พร้อม Validation // 1. Setup Config Module พร้อม Validation
ConfigModule.forRoot({ ConfigModule.forRoot({
isGlobal: true, isGlobal: true,
envFilePath: '.env', envFilePath: ['.env', '.env.local'],
load: [redisConfig], load: [redisConfig],
validationSchema: envValidationSchema, validationSchema: envValidationSchema,
validationOptions: { validationOptions: {
+2 -2
View File
@@ -4,8 +4,8 @@ export const databaseConfig: TypeOrmModuleOptions = {
type: 'mysql', type: 'mysql',
host: process.env.DB_HOST || 'localhost', host: process.env.DB_HOST || 'localhost',
port: Number(process.env.DB_PORT || '3306'), port: Number(process.env.DB_PORT || '3306'),
username: process.env.DB_USERNAME || 'root', username: process.env.DB_USERNAME || 'admin',
password: process.env.DB_PASSWORD || 'Center#2025', password: process.env.DB_PASSWORD || 'Center2025',
database: process.env.DB_DATABASE || 'lcbp3_dev', database: process.env.DB_DATABASE || 'lcbp3_dev',
charset: 'utf8mb4', charset: 'utf8mb4',
entities: [__dirname + '/../**/*.entity{.ts,.js}'], entities: [__dirname + '/../**/*.entity{.ts,.js}'],
+1 -1
View File
@@ -97,7 +97,7 @@ export async function seedUsers(dataSource: DataSource) {
]; ];
const salt = await bcrypt.genSalt(); const salt = await bcrypt.genSalt();
const password = await bcrypt.hash('password123', salt); // Default password const password = await bcrypt.hash('Center2025', salt); // Default password (ADR-019 aligned)
for (const u of usersData) { for (const u of usersData) {
let user = await userRepo.findOneBy({ username: u.username }); let user = await userRepo.findOneBy({ username: u.username });
@@ -8,15 +8,16 @@ import { Can } from '@/components/common/can';
export const dynamic = 'force-dynamic'; export const dynamic = 'force-dynamic';
interface CorrespondencesPageProps { interface CorrespondencesPageProps {
searchParams?: { searchParams: Promise<{
type?: string; type?: string;
}; }>;
} }
export default function CorrespondencesPage({ export default async function CorrespondencesPage({
searchParams, searchParams,
}: CorrespondencesPageProps) { }: CorrespondencesPageProps) {
const isRfaView = searchParams?.type?.toUpperCase() === 'RFA'; const params = await searchParams;
const isRfaView = params?.type?.toUpperCase() === 'RFA';
const heading = isRfaView ? 'RFAs (Request for Approval)' : 'Correspondences'; const heading = isRfaView ? 'RFAs (Request for Approval)' : 'Correspondences';
const description = isRfaView const description = isRfaView
? 'Unified list view for RFA documents' ? 'Unified list view for RFA documents'
+6 -3
View File
@@ -7,6 +7,7 @@ import QueryProvider from '@/providers/query-provider';
import SessionProvider from '@/providers/session-provider'; // ✅ Import เข้ามา import SessionProvider from '@/providers/session-provider'; // ✅ Import เข้ามา
import ThemeProvider from '@/providers/theme-provider'; import ThemeProvider from '@/providers/theme-provider';
import { Toaster } from '@/components/ui/sonner'; import { Toaster } from '@/components/ui/sonner';
import { headers } from 'next/headers';
const inter = Inter({ subsets: ['latin'] }); const inter = Inter({ subsets: ['latin'] });
@@ -19,13 +20,15 @@ interface RootLayoutProps {
children: React.ReactNode; children: React.ReactNode;
} }
export default function RootLayout({ children }: RootLayoutProps) { export default async function RootLayout({ children }: RootLayoutProps) {
const nonce = (await headers()).get('x-nonce') || '';
return ( return (
<html lang="en" suppressHydrationWarning> <html lang="en" suppressHydrationWarning>
<head /> <head />
<body className={cn('min-h-screen bg-background font-sans antialiased', inter.className)}> <body className={cn('min-h-screen bg-background font-sans antialiased', inter.className)}>
<SessionProvider> <SessionProvider nonce={nonce}>
<ThemeProvider> <ThemeProvider nonce={nonce}>
<QueryProvider> <QueryProvider>
{children} {children}
<Toaster /> <Toaster />
+10 -1
View File
@@ -2,7 +2,7 @@
'use client'; 'use client';
import { useAuthStore } from '@/lib/stores/auth-store'; import { useAuthStore } from '@/lib/stores/auth-store';
import { ReactNode } from 'react'; import { ReactNode, useEffect, useState } from 'react';
interface CanProps { interface CanProps {
permission?: string; permission?: string;
@@ -18,6 +18,15 @@ interface CanProps {
export function Can({ permission, role, children, fallback = null }: CanProps) { export function Can({ permission, role, children, fallback = null }: CanProps) {
const { hasPermission, hasRole } = useAuthStore(); const { hasPermission, hasRole } = useAuthStore();
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
if (!mounted) {
return <>{fallback}</>;
}
let allowed = true; let allowed = true;
+4 -4
View File
@@ -162,13 +162,13 @@ export const {
} }
return { return {
id: backendData.user.user_id.toString(), id: backendData.user.publicId, // ✅ Use publicId for session identity (ADR-019)
publicId: backendData.user.publicId, // ✅ Added (ADR-019 Waived for session) publicId: backendData.user.publicId,
name: `${backendData.user.firstName ?? ''} ${backendData.user.lastName ?? ''}`.trim(), name: `${backendData.user.firstName ?? ''} ${backendData.user.lastName ?? ''}`.trim(),
email: backendData.user.email, email: backendData.user.email,
username: backendData.user.username, username: backendData.user.username,
firstName: backendData.user.firstName, // ✅ Added firstName: backendData.user.firstName,
lastName: backendData.user.lastName, // ✅ Added lastName: backendData.user.lastName,
role: backendData.user.role || 'User', role: backendData.user.role || 'User',
organizationId: backendData.user.primaryOrganizationId, organizationId: backendData.user.primaryOrganizationId,
accessToken: backendData.access_token, accessToken: backendData.access_token,
+2 -2
View File
@@ -3,8 +3,8 @@ import { create } from 'zustand';
import { persist } from 'zustand/middleware'; import { persist } from 'zustand/middleware';
export interface User { export interface User {
id: string; // Internal stringified INT (for stability) id: string; // publicId (ADR-019)
publicId?: string; // ADR-019: Public UUIDv7 publicId: string;
username: string; username: string;
email: string; email: string;
firstName: string; firstName: string;
-12
View File
@@ -78,18 +78,6 @@ const nextConfig = {
key: 'X-Content-Type-Options', key: 'X-Content-Type-Options',
value: 'nosniff', value: 'nosniff',
}, },
{
key: 'Content-Security-Policy',
value: [
"default-src 'self'",
"script-src 'self' 'unsafe-eval'", // จำเป็นสำหรับ Workflow DSL Engine (new Function())
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data: https:",
"font-src 'self'",
"connect-src 'self' ws: wss:",
"frame-src 'self'",
].join('; '),
},
], ],
}, },
]; ];
+8 -2
View File
@@ -4,9 +4,15 @@
import { SessionProvider as NextAuthSessionProvider } from 'next-auth/react'; import { SessionProvider as NextAuthSessionProvider } from 'next-auth/react';
import { AuthSync } from '@/components/auth/auth-sync'; import { AuthSync } from '@/components/auth/auth-sync';
export default function SessionProvider({ children }: { children: React.ReactNode }) { export default function SessionProvider({
children,
nonce,
}: {
children: React.ReactNode;
nonce?: string;
}) {
return ( return (
<NextAuthSessionProvider> <NextAuthSessionProvider nonce={nonce}>
<AuthSync /> <AuthSync />
{children} {children}
</NextAuthSessionProvider> </NextAuthSessionProvider>
+3
View File
@@ -4,8 +4,10 @@ import { ThemeProvider as NextThemesProvider } from 'next-themes';
export default function ThemeProvider({ export default function ThemeProvider({
children, children,
nonce,
}: { }: {
children: React.ReactNode; children: React.ReactNode;
nonce?: string;
}) { }) {
return ( return (
<NextThemesProvider <NextThemesProvider
@@ -13,6 +15,7 @@ export default function ThemeProvider({
defaultTheme="dark" defaultTheme="dark"
enableSystem={false} enableSystem={false}
themes={['light', 'dark']} themes={['light', 'dark']}
nonce={nonce}
> >
{children} {children}
</NextThemesProvider> </NextThemesProvider>
+41 -1
View File
@@ -43,7 +43,47 @@ export default auth((req) => {
} }
} }
return NextResponse.next(); // แก้ไขจาก null // 5. Generate CSP with Nonce (Security Rule Tier 1)
// ใช้ Nonce Strategy เพื่ออนุญาต Inline Script เฉพาะที่ระบุตัวตนได้ ป้องกัน XSS
const nonce = Buffer.from(crypto.randomUUID()).toString('base64');
let connectSrcApi = 'http://localhost:3001';
if (process.env.NEXT_PUBLIC_API_URL) {
try {
connectSrcApi = new URL(process.env.NEXT_PUBLIC_API_URL).origin;
} catch {
connectSrcApi = process.env.NEXT_PUBLIC_API_URL;
}
}
const cspHeader = `
default-src 'self';
script-src 'self' 'nonce-${nonce}' 'strict-dynamic' 'unsafe-eval';
style-src 'self' 'unsafe-inline';
img-src 'self' blob: data: https:;
font-src 'self' data:;
connect-src 'self' ws: wss: ${connectSrcApi};
object-src 'none';
base-uri 'self';
form-action 'self';
frame-ancestors 'none';
upgrade-insecure-requests;
`
.replace(/\s{2,}/g, ' ')
.trim();
const requestHeaders = new Headers(req.headers);
requestHeaders.set('x-nonce', nonce);
requestHeaders.set('Content-Security-Policy', cspHeader);
const response = NextResponse.next({
request: {
headers: requestHeaders,
},
});
response.headers.set('Content-Security-Policy', cspHeader);
return response;
}); });
// กำหนดว่า Middleware นี้จะทำงานกับ Route ไหนบ้าง // กำหนดว่า Middleware นี้จะทำงานกับ Route ไหนบ้าง
+12 -12
View File
@@ -4,11 +4,11 @@ import _NextAuth, { DefaultSession } from 'next-auth';
declare module 'next-auth' { declare module 'next-auth' {
interface Session { interface Session {
user: { user: {
id: string; id: string; // publicId (ADR-019)
publicId: string; // ✅ Added (ADR-019 Waived for session) publicId: string;
username: string; // ✅ Added username: string;
firstName: string; // ✅ Added firstName: string;
lastName: string; // ✅ Added lastName: string;
role: string; role: string;
organizationId?: number; organizationId?: number;
} & DefaultSession['user']; } & DefaultSession['user'];
@@ -18,11 +18,11 @@ declare module 'next-auth' {
} }
interface User { interface User {
id: string; id: string; // publicId (ADR-019)
publicId: string; // ✅ Added publicId: string;
username: string; // ✅ Added username: string;
firstName: string; // ✅ Added firstName: string;
lastName: string; // ✅ Added lastName: string;
role: string; role: string;
organizationId?: number; organizationId?: number;
accessToken?: string; accessToken?: string;
@@ -32,8 +32,8 @@ declare module 'next-auth' {
declare module 'next-auth/jwt' { declare module 'next-auth/jwt' {
interface JWT { interface JWT {
id: string; id: string; // publicId or username depending on auth flow
username: string; // ✅ Added username: string;
role: string; role: string;
organizationId?: number; organizationId?: number;
accessToken?: string; accessToken?: string;
+1 -1
View File
@@ -13,7 +13,7 @@
"start:mcp": "node ./scripts/start-mcp.js", "start:mcp": "node ./scripts/start-mcp.js",
"dev:backend": "pnpm --filter backend start:dev", "dev:backend": "pnpm --filter backend start:dev",
"dev:frontend": "pnpm --filter lcbp3-frontend dev", "dev:frontend": "pnpm --filter lcbp3-frontend dev",
"dev": "pnpm run --parallel /dev|start:dev/", "dev": "pnpm run --parallel \"/dev|start:dev/\"",
"prepare": "husky", "prepare": "husky",
"lint": "eslint .", "lint": "eslint .",
"lint:fix": "eslint . --fix" "lint:fix": "eslint . --fix"
@@ -2,7 +2,7 @@
**Status:** Accepted **Status:** Accepted
**Date:** 2026-03-12 **Date:** 2026-03-12
**Version:** 1.8.1 **Version:** 1.8.2
**Decision Makers:** Development Team, Database Architect **Decision Makers:** Development Team, Database Architect
**Related Documents:** **Related Documents:**
@@ -369,26 +369,6 @@ ALTER TABLE notifications
-- Using regular INDEX instead -- Using regular INDEX instead
``` ```
### Rollback SQL
```sql
-- Rollback: Remove UUID columns (Non-destructive reverse)
ALTER TABLE users DROP INDEX idx_users_uuid, DROP COLUMN uuid;
ALTER TABLE organizations DROP INDEX idx_organizations_uuid, DROP COLUMN uuid;
ALTER TABLE projects DROP INDEX idx_projects_uuid, DROP COLUMN uuid;
ALTER TABLE contracts DROP INDEX idx_contracts_uuid, DROP COLUMN uuid;
ALTER TABLE correspondences DROP INDEX idx_correspondences_uuid, DROP COLUMN uuid;
ALTER TABLE correspondence_revisions DROP INDEX idx_correspondence_revisions_uuid, DROP COLUMN uuid;
ALTER TABLE circulations DROP INDEX idx_circulations_uuid, DROP COLUMN uuid;
ALTER TABLE shop_drawings DROP INDEX idx_shop_drawings_uuid, DROP COLUMN uuid;
ALTER TABLE shop_drawing_revisions DROP INDEX idx_shop_drawing_revisions_uuid, DROP COLUMN uuid;
ALTER TABLE contract_drawings DROP INDEX idx_contract_drawings_uuid, DROP COLUMN uuid;
ALTER TABLE asbuilt_drawings DROP INDEX idx_asbuilt_drawings_uuid, DROP COLUMN uuid;
ALTER TABLE asbuilt_drawing_revisions DROP INDEX idx_asbuilt_drawing_revisions_uuid, DROP COLUMN uuid;
ALTER TABLE attachments DROP INDEX idx_attachments_uuid, DROP COLUMN uuid;
ALTER TABLE notifications DROP INDEX idx_notifications_uuid, DROP COLUMN uuid;
```
--- ---
## Storage Impact Analysis ## Storage Impact Analysis
@@ -530,17 +510,12 @@ type ProjectOption = {
--- ---
## Waivers & Exceptions ## 🔄 Change Log
### 1. AuthStore / Frontend Session User Identity | Version | Date | Changes | Updated By |
| ------- | ---------- | ------------------------------------------------------------------- | ----------- |
**Date:** 2026-04-01 | 1.8.2 | 2026-04-01 | Removed Waiver: Session Identity to enforce strict `publicId` usage | Antigravity |
**Scope:** `frontend/lib/stores/auth-store.ts`, `frontend/lib/auth.ts` | 1.8.1 | 2026-03-21 | Added Naming Convention Summary & Transition Strategy | Claude |
| 1.8.0 | 2026-03-12 | Initial Decision Outcome & Technical Spec | Human Dev |
**Decision:** ให้คงฟิลด์ `id` (stringified `user_id` INT) ไว้ใน `User` interface ของ `AuthStore` และ `NextAuth Session` เพื่อความเสถียรของระบบ Login ที่ใช้งานได้ดีอยู่แล้ว โดยให้เพิ่ม `publicId` เป็นฟิลด์เสริมแทนการ Replacement (Waive strict ADR-019 compliance for Session Identity only).
**Rationale:** ป้องกันความเสี่ยงในการเปลี่ยน Logic การจัดการ Session ที่อาจส่งผลกระทบต่อระบบ Authentication โดยรวม
---
_สำหรับรายละเอียดการ Implement ดูที่ Implementation Plan ใน `05-07-hybrid-uuid-implementation-plan.md`_ _สำหรับรายละเอียดการ Implement ดูที่ Implementation Plan ใน `05-07-hybrid-uuid-implementation-plan.md`_