Almost complete org/permission handling. Much cleanup, bootstrap works.

This commit is contained in:
Leo Vasanko 2025-08-07 13:58:12 -06:00
parent 2e4ff30bea
commit 407994548a
12 changed files with 225 additions and 341 deletions

View File

@ -2,7 +2,6 @@
<div> <div>
<StatusMessage /> <StatusMessage />
<LoginView v-if="store.currentView === 'login'" /> <LoginView v-if="store.currentView === 'login'" />
<RegisterView v-if="store.currentView === 'register'" />
<ProfileView v-if="store.currentView === 'profile'" /> <ProfileView v-if="store.currentView === 'profile'" />
<DeviceLinkView v-if="store.currentView === 'device-link'" /> <DeviceLinkView v-if="store.currentView === 'device-link'" />
<ResetView v-if="store.currentView === 'reset'" /> <ResetView v-if="store.currentView === 'reset'" />
@ -14,7 +13,6 @@ import { onMounted } from 'vue'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import StatusMessage from '@/components/StatusMessage.vue' import StatusMessage from '@/components/StatusMessage.vue'
import LoginView from '@/components/LoginView.vue' import LoginView from '@/components/LoginView.vue'
import RegisterView from '@/components/RegisterView.vue'
import ProfileView from '@/components/ProfileView.vue' import ProfileView from '@/components/ProfileView.vue'
import DeviceLinkView from '@/components/DeviceLinkView.vue' import DeviceLinkView from '@/components/DeviceLinkView.vue'
import ResetView from '@/components/ResetView.vue' import ResetView from '@/components/ResetView.vue'

View File

@ -11,11 +11,6 @@
{{ authStore.isLoading ? 'Authenticating...' : 'Login with Your Device' }} {{ authStore.isLoading ? 'Authenticating...' : 'Login with Your Device' }}
</button> </button>
</form> </form>
<p class="toggle-link">
<a href="#" @click.prevent="authStore.currentView = 'register'">
Don't have an account? Register here
</a>
</p>
</div> </div>
</div> </div>
</template> </template>

View File

@ -81,7 +81,7 @@
import { ref, onMounted, onUnmounted } from 'vue' import { ref, onMounted, onUnmounted } from 'vue'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import { formatDate } from '@/utils/helpers' import { formatDate } from '@/utils/helpers'
import { registerCredential } from '@/utils/passkey' import passkey from '@/utils/passkey'
const authStore = useAuthStore() const authStore = useAuthStore()
const updateInterval = ref(null) const updateInterval = ref(null)
@ -119,7 +119,7 @@ const addNewCredential = async () => {
try { try {
authStore.isLoading = true authStore.isLoading = true
authStore.showMessage('Adding new passkey...', 'info') authStore.showMessage('Adding new passkey...', 'info')
const result = await registerCredential() await passkey.register()
await authStore.loadUserInfo() await authStore.loadUserInfo()
authStore.showMessage('New passkey added successfully!', 'success', 3000) authStore.showMessage('New passkey added successfully!', 'success', 3000)
} catch (error) { } catch (error) {

View File

@ -1,57 +0,0 @@
<template>
<div class="container">
<div class="view active">
<h1>🔐 Create Account</h1>
<form @submit.prevent="handleRegister">
<input
type="text"
v-model="user_name"
placeholder="Enter username"
required
:disabled="authStore.isLoading"
>
<button
type="submit"
class="btn-primary"
:disabled="authStore.isLoading || !user_name.trim()"
>
{{ authStore.isLoading ? 'Registering...' : 'Register Passkey' }}
</button>
</form>
<p class="toggle-link">
<a href="#" @click.prevent="authStore.currentView = 'login'">
Already have an account? Login here
</a>
</p>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { useAuthStore } from '@/stores/auth'
const authStore = useAuthStore()
const user_name = ref('')
const handleRegister = async () => {
if (!user_name.value.trim()) return
try {
authStore.showMessage('Starting registration...', 'info')
await authStore.register(user_name.value.trim())
authStore.showMessage('Passkey registered successfully!', 'success', 2000)
setTimeout(() => {
authStore.currentView = 'profile'
}, 1500)
} catch (error) {
console.error('Registration error:', error)
if (error.name === "NotAllowedError") {
authStore.showMessage('Registration cancelled', 'error')
} else {
authStore.showMessage(`Registration failed: ${error.message}`, 'error')
}
}
}
</script>

View File

@ -3,6 +3,7 @@
<div class="view active"> <div class="view active">
<h1>🔑 Add New Credential</h1> <h1>🔑 Add New Credential</h1>
<h3>👤 {{ authStore.userInfo?.user?.user_name }}</h3> <h3>👤 {{ authStore.userInfo?.user?.user_name }}</h3>
<!-- TODO: allow editing name <input type="text" v-model="user_name" required :disabled="authStore.isLoading"> -->
<p>Proceed to complete {{authStore.userInfo?.session_type}}:</p> <p>Proceed to complete {{authStore.userInfo?.session_type}}:</p>
<button <button
class="btn-primary" class="btn-primary"
@ -17,7 +18,7 @@
<script setup> <script setup>
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import { registerCredential } from '@/utils/passkey' import passkey from '@/utils/passkey'
const authStore = useAuthStore() const authStore = useAuthStore()
@ -26,8 +27,7 @@ async function register() {
authStore.showMessage('Starting registration...', 'info') authStore.showMessage('Starting registration...', 'info')
try { try {
// TODO: For reset sessions, might use registerWithToken() in the future const result = await passkey.register()
const result = await registerCredential()
console.log("Result", result) console.log("Result", result)
await authStore.setSessionCookie(result.session_token) await authStore.setSessionCookie(result.session_token)

View File

@ -1,5 +1,5 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { registerUser, authenticateUser, registerWithToken } from '@/utils/passkey' import { register, authenticate } from '@/utils/passkey'
export const useAuthStore = defineStore('auth', { export const useAuthStore = defineStore('auth', {
state: () => ({ state: () => ({
@ -8,7 +8,7 @@ export const useAuthStore = defineStore('auth', {
isLoading: false, isLoading: false,
// UI State // UI State
currentView: 'login', // 'login', 'register', 'profile', 'reset' currentView: 'login', // 'login', 'profile', 'device-link', 'reset'
status: { status: {
message: '', message: '',
type: 'info', type: 'info',
@ -39,23 +39,12 @@ export const useAuthStore = defineStore('auth', {
} }
return result return result
}, },
async register(user_name) { async register() {
this.isLoading = true this.isLoading = true
try { try {
const result = await registerUser(user_name) const result = await register()
this.userInfo = {
user: {
user_id: result.user_id,
user_name: user_name,
},
credentials: [],
aaguid_info: {},
session_type: null,
authenticated: false
}
await this.setSessionCookie(result.session_token) await this.setSessionCookie(result.session_token)
await this.loadUserInfo()
return result return result
} finally { } finally {
this.isLoading = false this.isLoading = false
@ -64,7 +53,7 @@ export const useAuthStore = defineStore('auth', {
async authenticate() { async authenticate() {
this.isLoading = true this.isLoading = true
try { try {
const result = await authenticateUser() const result = await authenticate()
await this.setSessionCookie(result.session_token) await this.setSessionCookie(result.session_token)
await this.loadUserInfo() await this.loadUserInfo()

View File

@ -1,14 +1,13 @@
import { startRegistration, startAuthentication } from '@simplewebauthn/browser' import { startRegistration, startAuthentication } from '@simplewebauthn/browser'
import aWebSocket from '@/utils/awaitable-websocket' import aWebSocket from '@/utils/awaitable-websocket'
export async function register(url, options) { export async function register() {
if (options) url += `?${new URLSearchParams(options).toString()}` const ws = await aWebSocket("/auth/ws/register")
const ws = await aWebSocket(url)
try { try {
const optionsJSON = await ws.receive_json() const optionsJSON = await ws.receive_json()
const registrationResponse = await startRegistration({ optionsJSON }) const registrationResponse = await startRegistration({ optionsJSON })
ws.send_json(registrationResponse) ws.send_json(registrationResponse)
const result = await ws.receive_json() return await ws.receive_json()
} catch (error) { } catch (error) {
console.error('Registration error:', error) console.error('Registration error:', error)
// Replace useless and ugly error message from startRegistration // Replace useless and ugly error message from startRegistration
@ -18,18 +17,7 @@ export async function register(url, options) {
} }
} }
export async function registerUser(user_name) { export async function authenticate() {
return register('/auth/ws/register', { user_name })
}
export async function registerCredential() {
return register('/auth/ws/add_credential')
}
export async function registerWithToken(token) {
return register('/auth/ws/add_credential', { token })
}
export async function authenticateUser() {
const ws = await aWebSocket('/auth/ws/authenticate') const ws = await aWebSocket('/auth/ws/authenticate')
try { try {
const optionsJSON = await ws.receive_json() const optionsJSON = await ws.receive_json()
@ -44,3 +32,5 @@ export async function authenticateUser() {
ws.close() ws.close()
} }
} }
export default { authenticate, register }

View File

@ -13,7 +13,7 @@ from datetime import datetime
import uuid7 import uuid7
from . import authsession, globals from . import authsession, globals
from .db import Org, Permission, User from .db import Org, Permission, Role, User
from .util import passphrase, tokens from .util import passphrase, tokens
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -54,62 +54,42 @@ async def bootstrap_system(
dict: Contains information about created entities and reset link dict: Contains information about created entities and reset link
""" """
# Create permission first - will fail if already exists # Create permission first - will fail if already exists
permission = Permission(id="auth/admin", display_name="Admin") perm0 = Permission(id="auth/admin", display_name="Master Admin")
await globals.db.instance.create_permission(permission) await globals.db.instance.create_permission(perm0)
# Create organization org = Org(uuid7.create(), org_name or "Organization")
org_uuid = uuid7.create()
org = Org(
id=str(org_uuid),
options={
"display_name": org_name or "Organization",
"created_at": datetime.now().isoformat(),
},
)
await globals.db.instance.create_organization(org) await globals.db.instance.create_organization(org)
# Create admin user perm1 = Permission(
admin_uuid = uuid7.create() id=f"auth/org:{org.uuid}", display_name=f"{org.display_name} Admin"
)
await globals.db.instance.create_permission(perm1)
role = Role(uuid7.create(), org.uuid, "Administration")
await globals.db.instance.create_role(role)
user = User( user = User(
uuid=admin_uuid, uuid=uuid7.create(),
display_name=user_name or "Admin", display_name=user_name or "Admin",
org_uuid=org_uuid, role_uuid=role.uuid,
role="Admin",
created_at=datetime.now(), created_at=datetime.now(),
visits=0, visits=0,
) )
await globals.db.instance.create_user(user) await globals.db.instance.create_user(user)
# Link user to organization and assign permissions
await globals.db.instance.add_user_to_organization(
user_uuid=admin_uuid, org_id=org.id, role="Admin"
)
await globals.db.instance.add_permission_to_organization(org.id, permission.id)
# Generate reset link and log it # Generate reset link and log it
reset_link = await _create_and_log_admin_reset_link( reset_link = await _create_and_log_admin_reset_link(
admin_uuid, "✅ Bootstrap completed!", "admin bootstrap" user.uuid, "✅ Bootstrap completed!", "admin bootstrap"
) )
result = { return {
"admin_user": { "user": user,
"uuid": str(user.uuid), "org": org,
"display_name": user.display_name, "role": role,
"role": user.role, "permissions": [perm0, perm1],
},
"organization": {
"id": org.id,
"display_name": org.options.get("display_name"),
},
"permission": {
"id": permission.id,
"display_name": permission.display_name,
},
"reset_link": reset_link, "reset_link": reset_link,
} }
return result
async def check_admin_credentials() -> bool: async def check_admin_credentials() -> bool:
""" """
@ -175,14 +155,7 @@ async def bootstrap_if_needed(
await globals.db.instance.get_permission("auth/admin") await globals.db.instance.get_permission("auth/admin")
# Permission exists, system is already bootstrapped # Permission exists, system is already bootstrapped
# Check if admin needs credentials (only for already-bootstrapped systems) # Check if admin needs credentials (only for already-bootstrapped systems)
admin_needs_reset = await check_admin_credentials() await check_admin_credentials()
if not admin_needs_reset:
# Use the same format as the reset link messages
logger.info(
ADMIN_RESET_MESSAGE,
" System already bootstrapped - no action needed",
"Admin user already has credentials",
)
return False return False
except Exception: except Exception:
# Permission doesn't exist, need to bootstrap # Permission doesn't exist, need to bootstrap

View File

@ -11,12 +11,24 @@ from datetime import datetime
from uuid import UUID from uuid import UUID
@dataclass
class Org:
uuid: UUID
display_name: str
@dataclass
class Role:
uuid: UUID
org_uuid: UUID
display_name: str
@dataclass @dataclass
class User: class User:
uuid: UUID uuid: UUID
display_name: str display_name: str
org_uuid: UUID role_uuid: UUID
role: str | None = None
created_at: datetime | None = None created_at: datetime | None = None
last_seen: datetime | None = None last_seen: datetime | None = None
visits: int = 0 visits: int = 0
@ -35,26 +47,8 @@ class Credential:
last_verified: datetime | None = None last_verified: datetime | None = None
@dataclass
class Org:
"""Organization data structure."""
id: str # ASCII primary key
options: dict
@dataclass
class Permission:
"""Permission data structure."""
id: str # String primary key (max 32 chars)
display_name: str
@dataclass @dataclass
class Session: class Session:
"""Session data structure."""
key: bytes key: bytes
user_uuid: UUID user_uuid: UUID
expires: datetime expires: datetime
@ -63,13 +57,17 @@ class Session:
@dataclass @dataclass
class SessionContext: class Permission:
"""Complete session context with user, organization, role, and permissions.""" id: str # String primary key (max 128 chars)
display_name: str
@dataclass
class SessionContext:
session: Session session: Session
user: User user: User
organization: Org org: Org
role: str | None = None role: Role
permissions: list[Permission] | None = None permissions: list[Permission] | None = None
@ -96,6 +94,11 @@ class DatabaseInterface(ABC):
async def create_user(self, user: User) -> None: async def create_user(self, user: User) -> None:
"""Create a new user.""" """Create a new user."""
# Role operations
@abstractmethod
async def create_role(self, role: Role) -> None:
"""Create new role."""
# Credential operations # Credential operations
@abstractmethod @abstractmethod
async def create_credential(self, credential: Credential) -> None: async def create_credential(self, credential: Credential) -> None:
@ -149,19 +152,19 @@ class DatabaseInterface(ABC):
# Organization operations # Organization operations
@abstractmethod @abstractmethod
async def create_organization(self, organization: Org) -> None: async def create_organization(self, org: Org) -> None:
"""Create a new organization.""" """Add a new organization."""
@abstractmethod @abstractmethod
async def get_organization(self, org_id: str) -> Org: async def get_organization(self, org_id: str) -> Org:
"""Get organization by ID.""" """Get organization by ID."""
@abstractmethod @abstractmethod
async def update_organization(self, organization: Org) -> None: async def update_organization(self, org: Org) -> None:
"""Update organization options.""" """Update organization options."""
@abstractmethod @abstractmethod
async def delete_organization(self, org_id: str) -> None: async def delete_organization(self, org_uuid: UUID) -> None:
"""Delete organization by ID.""" """Delete organization by ID."""
@abstractmethod @abstractmethod

View File

@ -29,6 +29,7 @@ from . import (
DatabaseInterface, DatabaseInterface,
Org, Org,
Permission, Permission,
Role,
Session, Session,
SessionContext, SessionContext,
User, User,
@ -42,41 +43,47 @@ async def init(*args, **kwargs):
await db.instance.init_db() await db.instance.init_db()
# SQLAlchemy Models
class Base(DeclarativeBase): class Base(DeclarativeBase):
pass pass
# Association model for many-to-many relationship between organizations and permissions
class OrgPermission(Base):
"""Permissions each Org is allowed to grant to its roles."""
__tablename__ = "org_permissions"
org_uuid: Mapped[bytes] = mapped_column(
LargeBinary(16),
ForeignKey("orgs.uuid", ondelete="CASCADE"),
primary_key=True,
)
permission_id: Mapped[str] = mapped_column(
String(32),
ForeignKey("permissions.id", ondelete="CASCADE"),
primary_key=True,
)
class PermissionModel(Base):
__tablename__ = "permissions"
id: Mapped[str] = mapped_column(String(128), primary_key=True)
display_name: Mapped[str] = mapped_column(String, nullable=False)
class OrgModel(Base): class OrgModel(Base):
__tablename__ = "orgs" __tablename__ = "orgs"
uuid: Mapped[bytes] = mapped_column(LargeBinary(16), primary_key=True) uuid: Mapped[bytes] = mapped_column(LargeBinary(16), primary_key=True)
options: Mapped[dict] = mapped_column(JSON, nullable=False, default=dict) display_name: Mapped[str] = mapped_column(String, nullable=False)
def as_dataclass(self):
return Org(UUID(bytes=self.uuid), self.display_name)
@staticmethod
def from_dataclass(org: Org):
return OrgModel(uuid=org.uuid.bytes, display_name=org.display_name)
class RoleModel(Base):
__tablename__ = "roles"
uuid: Mapped[bytes] = mapped_column(LargeBinary(16), primary_key=True)
org_uuid: Mapped[bytes] = mapped_column(
LargeBinary(16), ForeignKey("orgs.uuid", ondelete="CASCADE"), nullable=False
)
display_name: Mapped[str] = mapped_column(String, nullable=False)
def as_dataclass(self):
return Role(
uuid=UUID(bytes=self.uuid),
org_uuid=UUID(bytes=self.org_uuid),
display_name=self.display_name,
)
@staticmethod
def from_dataclass(role: Role):
return RoleModel(
uuid=role.uuid.bytes,
org_uuid=role.org_uuid.bytes,
display_name=role.display_name,
)
class UserModel(Base): class UserModel(Base):
@ -84,17 +91,38 @@ class UserModel(Base):
uuid: Mapped[bytes] = mapped_column(LargeBinary(16), primary_key=True) uuid: Mapped[bytes] = mapped_column(LargeBinary(16), primary_key=True)
display_name: Mapped[str] = mapped_column(String, nullable=False) display_name: Mapped[str] = mapped_column(String, nullable=False)
org_uuid: Mapped[bytes] = mapped_column( role_uuid: Mapped[bytes] = mapped_column(
LargeBinary(16), ForeignKey("orgs.uuid", ondelete="CASCADE"), nullable=False LargeBinary(16), ForeignKey("roles.uuid", ondelete="CASCADE"), nullable=False
) )
role: Mapped[str | None] = mapped_column(String, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.now) created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.now)
last_seen: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) last_seen: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
visits: Mapped[int] = mapped_column(Integer, nullable=False, default=0) visits: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
def as_dataclass(self) -> User:
return User(
uuid=UUID(bytes=self.uuid),
display_name=self.display_name,
role_uuid=UUID(bytes=self.role_uuid),
created_at=self.created_at,
last_seen=self.last_seen,
visits=self.visits,
)
@staticmethod
def from_dataclass(user: User):
return UserModel(
uuid=user.uuid.bytes,
display_name=user.display_name,
role_uuid=user.role_uuid.bytes,
created_at=user.created_at or datetime.now(),
last_seen=user.last_seen,
visits=user.visits,
)
class CredentialModel(Base): class CredentialModel(Base):
__tablename__ = "credentials" __tablename__ = "credentials"
uuid: Mapped[bytes] = mapped_column(LargeBinary(16), primary_key=True) uuid: Mapped[bytes] = mapped_column(LargeBinary(16), primary_key=True)
credential_id: Mapped[bytes] = mapped_column( credential_id: Mapped[bytes] = mapped_column(
LargeBinary(64), unique=True, index=True LargeBinary(64), unique=True, index=True
@ -121,7 +149,73 @@ class SessionModel(Base):
LargeBinary(16), ForeignKey("credentials.uuid", ondelete="CASCADE") LargeBinary(16), ForeignKey("credentials.uuid", ondelete="CASCADE")
) )
expires: Mapped[datetime] = mapped_column(DateTime, nullable=False) expires: Mapped[datetime] = mapped_column(DateTime, nullable=False)
info: Mapped[dict | None] = mapped_column(JSON, nullable=True) info: Mapped[dict] = mapped_column(JSON, default=dict)
def as_dataclass(self):
return Session(
key=self.key,
user_uuid=UUID(bytes=self.user_uuid),
credential_uuid=(
UUID(bytes=self.credential_uuid) if self.credential_uuid else None
),
expires=self.expires,
info=self.info,
)
@staticmethod
def from_dataclass(session: Session):
return SessionModel(
key=session.key,
user_uuid=session.user_uuid.bytes,
credential_uuid=session.credential_uuid and session.credential_uuid.bytes,
expires=session.expires,
info=session.info,
)
class PermissionModel(Base):
__tablename__ = "permissions"
id: Mapped[str] = mapped_column(String(64), primary_key=True)
display_name: Mapped[str] = mapped_column(String, nullable=False)
def as_dataclass(self):
return Permission(self.id, self.display_name)
@staticmethod
def from_dataclass(permission: Permission):
return PermissionModel(id=permission.id, display_name=permission.display_name)
## Join tables (no dataclass equivalents)
class OrgPermission(Base):
"""Permissions each organization is allowed to grant to its roles."""
__tablename__ = "org_permissions"
id: Mapped[int] = mapped_column(Integer, primary_key=True) # Not used
org_uuid: Mapped[bytes] = mapped_column(
LargeBinary(16), ForeignKey("orgs.uuid", ondelete="CASCADE")
)
permission_id: Mapped[str] = mapped_column(
String(64), ForeignKey("permissions.id", ondelete="CASCADE")
)
class RolePermission(Base):
"""Permissions that each role grants to its members."""
__tablename__ = "role_permissions"
id: Mapped[int] = mapped_column(Integer, primary_key=True) # Not used
role_uuid: Mapped[bytes] = mapped_column(
LargeBinary(16), ForeignKey("roles.uuid", ondelete="CASCADE")
)
permission_id: Mapped[str] = mapped_column(
String(64), ForeignKey("permissions.id", ondelete="CASCADE")
)
class DB(DatabaseInterface): class DB(DatabaseInterface):
@ -155,29 +249,16 @@ class DB(DatabaseInterface):
user_model = result.scalar_one_or_none() user_model = result.scalar_one_or_none()
if user_model: if user_model:
return User( return user_model.as_dataclass()
uuid=UUID(bytes=user_model.uuid),
display_name=user_model.display_name,
org_uuid=UUID(bytes=user_model.org_uuid),
role=user_model.role,
created_at=user_model.created_at,
last_seen=user_model.last_seen,
visits=user_model.visits,
)
raise ValueError("User not found") raise ValueError("User not found")
async def create_user(self, user: User) -> None: async def create_user(self, user: User) -> None:
async with self.session() as session: async with self.session() as session:
user_model = UserModel( session.add(UserModel.from_dataclass(user))
uuid=user.uuid.bytes,
display_name=user.display_name, async def create_role(self, role: Role) -> None:
org_uuid=user.org_uuid.bytes, async with self.session() as session:
role=user.role, session.add(RoleModel.from_dataclass(role))
created_at=user.created_at or datetime.now(),
last_seen=user.last_seen,
visits=user.visits,
)
session.add(user_model)
async def create_credential(self, credential: Credential) -> None: async def create_credential(self, credential: Credential) -> None:
async with self.session() as session: async with self.session() as session:
@ -265,19 +346,8 @@ class DB(DatabaseInterface):
self, user: User, credential: Credential self, user: User, credential: Credential
) -> None: ) -> None:
async with self.session() as session: async with self.session() as session:
# Set visits to 1 for the new user since they're creating their first session
user.visits = 1
# Create user # Create user
user_model = UserModel( user_model = UserModel.from_dataclass(user)
uuid=user.uuid.bytes,
display_name=user.display_name,
org_uuid=user.org_uuid.bytes,
role=user.role,
created_at=user.created_at or datetime.now(),
last_seen=user.last_seen,
visits=user.visits,
)
session.add(user_model) session.add(user_model)
# Create credential # Create credential
@ -352,13 +422,11 @@ class DB(DatabaseInterface):
) )
# Organization operations # Organization operations
async def create_organization(self, organization: Org) -> None: async def create_organization(self, org: Org) -> None:
async with self.session() as session: async with self.session() as session:
# Convert string ID to UUID bytes for storage
org_uuid = UUID(organization.id)
org_model = OrgModel( org_model = OrgModel(
uuid=org_uuid.bytes, uuid=org.uuid.bytes,
options=organization.options, display_name=org.display_name,
) )
session.add(org_model) session.add(org_model)
@ -370,34 +438,28 @@ class DB(DatabaseInterface):
result = await session.execute(stmt) result = await session.execute(stmt)
org_model = result.scalar_one_or_none() org_model = result.scalar_one_or_none()
if org_model: if not org_model:
# Convert UUID bytes back to string for the interface
return Org(
id=str(UUID(bytes=org_model.uuid)),
options=org_model.options,
)
raise ValueError("Organization not found") raise ValueError("Organization not found")
async def update_organization(self, organization: Org) -> None: return org_model.as_dataclass()
async def update_organization(self, org: Org) -> None:
async with self.session() as session: async with self.session() as session:
# Convert string ID to UUID bytes for lookup
org_uuid = UUID(organization.id)
stmt = ( stmt = (
update(OrgModel) update(OrgModel)
.where(OrgModel.uuid == org_uuid.bytes) .where(OrgModel.uuid == org.uuid.bytes)
.values(options=organization.options) .values(display_name=org.display_name)
) )
await session.execute(stmt) await session.execute(stmt)
async def delete_organization(self, org_id: str) -> None: async def delete_organization(self, org_uuid: UUID) -> None:
async with self.session() as session: async with self.session() as session:
# Convert string ID to UUID bytes for lookup # Convert string ID to UUID bytes for lookup
org_uuid = UUID(org_id)
stmt = delete(OrgModel).where(OrgModel.uuid == org_uuid.bytes) stmt = delete(OrgModel).where(OrgModel.uuid == org_uuid.bytes)
await session.execute(stmt) await session.execute(stmt)
async def add_user_to_organization( async def add_user_to_organization(
self, user_uuid: UUID, org_id: str, role: str self, user_uuid: UUID, org_uuid: UUID, role: str
) -> None: ) -> None:
async with self.session() as session: async with self.session() as session:
# Get user and organization models # Get user and organization models
@ -406,7 +468,6 @@ class DB(DatabaseInterface):
user_model = user_result.scalar_one_or_none() user_model = user_result.scalar_one_or_none()
# Convert string ID to UUID bytes for lookup # Convert string ID to UUID bytes for lookup
org_uuid = UUID(org_id)
org_stmt = select(OrgModel).where(OrgModel.uuid == org_uuid.bytes) org_stmt = select(OrgModel).where(OrgModel.uuid == org_uuid.bytes)
org_result = await session.execute(org_stmt) org_result = await session.execute(org_stmt)
org_model = org_result.scalar_one_or_none() org_model = org_result.scalar_one_or_none()
@ -449,7 +510,7 @@ class DB(DatabaseInterface):
if result.rowcount == 0: if result.rowcount == 0:
raise ValueError("User not found") raise ValueError("User not found")
async def get_user_organization(self, user_uuid: UUID) -> tuple[Org, str]: async def get_user_organization(self, user_uuid: UUID) -> Org:
async with self.session() as session: async with self.session() as session:
stmt = select(UserModel).where(UserModel.uuid == user_uuid.bytes) stmt = select(UserModel).where(UserModel.uuid == user_uuid.bytes)
result = await session.execute(stmt) result = await session.execute(stmt)
@ -459,37 +520,19 @@ class DB(DatabaseInterface):
raise ValueError("User not found") raise ValueError("User not found")
# Fetch the organization details # Fetch the organization details
org_stmt = select(OrgModel).where(OrgModel.uuid == user_model.org_uuid) org_stmt = select(OrgModel).where(OrgModel.uuid == user_model.role_uuid)
org_result = await session.execute(org_stmt) org_result = await session.execute(org_stmt)
org_model = org_result.scalar_one() org_model = org_result.scalar_one()
# Convert UUID bytes back to string for the interface # Convert UUID bytes back to string for the interface
org = Org(id=str(UUID(bytes=org_model.uuid)), options=org_model.options) return org_model.as_dataclass()
return (org, user_model.role or "")
async def get_organization_users(self, org_id: str) -> list[tuple[User, str]]: async def get_organization_users(self, org_id: str) -> list[User]:
async with self.session() as session: async with self.session() as session:
# Convert string ID to UUID bytes for lookup stmt = select(UserModel).where(UserModel.role_uuid == role.uuid.bytes)
org_uuid = UUID(org_id)
stmt = select(UserModel).where(UserModel.org_uuid == org_uuid.bytes)
result = await session.execute(stmt) result = await session.execute(stmt)
user_models = result.scalars().all() user_models = result.scalars().all()
return [u.as_dataclass() for u in user_models]
# Create user objects with their roles
user_role_pairs = []
for user_model in user_models:
user = User(
uuid=UUID(bytes=user_model.uuid),
display_name=user_model.display_name,
org_uuid=UUID(bytes=user_model.org_uuid),
role=user_model.role,
created_at=user_model.created_at,
last_seen=user_model.last_seen,
visits=user_model.visits,
)
user_role_pairs.append((user, user_model.role or ""))
return user_role_pairs
async def get_user_role_in_organization( async def get_user_role_in_organization(
self, user_uuid: UUID, org_id: str self, user_uuid: UUID, org_id: str
@ -638,8 +681,7 @@ class DB(DatabaseInterface):
org_model = org_result.scalar_one() org_model = org_result.scalar_one()
# Convert UUID bytes back to string for the interface # Convert UUID bytes back to string for the interface
org = Org(id=str(UUID(bytes=org_model.uuid)), options=org_model.options) organizations.append(org.as_dataclass())
organizations.append(org)
return organizations return organizations
@ -695,21 +737,10 @@ class DB(DatabaseInterface):
) )
# Create the user object # Create the user object
user_obj = User( user_obj = user_model.as_dataclass()
uuid=UUID(bytes=user_model.uuid),
display_name=user_model.display_name,
org_uuid=UUID(bytes=user_model.org_uuid),
role=user_model.role,
created_at=user_model.created_at,
last_seen=user_model.last_seen,
visits=user_model.visits,
)
# Create organization object (always exists now) # Create organization object (always exists now)
organization = Org( organization = Org(UUID(bytes=org_model.uuid), org_model.display_name)
id=str(UUID(bytes=org_model.uuid)),
options=org_model.options,
)
# Collect all unique permissions # Collect all unique permissions
permissions = [] permissions = []
@ -728,7 +759,7 @@ class DB(DatabaseInterface):
return SessionContext( return SessionContext(
session=session_obj, session=session_obj,
user=user_obj, user=user_obj,
organization=organization, org=organization,
role=user_model.role, role=user_model.role,
permissions=permissions if permissions else None, permissions=permissions if permissions else None,
) )

View File

@ -1,17 +1,13 @@
import logging import logging
from datetime import datetime
from functools import wraps from functools import wraps
from uuid import UUID from uuid import UUID
import uuid7 from fastapi import Cookie, FastAPI, WebSocket, WebSocketDisconnect
from fastapi import Cookie, FastAPI, Query, WebSocket, WebSocketDisconnect
from webauthn.helpers.exceptions import InvalidAuthenticationResponse from webauthn.helpers.exceptions import InvalidAuthenticationResponse
from ..authsession import EXPIRES, create_session, get_reset, get_session from ..authsession import create_session, get_reset, get_session
from ..db import User
from ..globals import db, passkey from ..globals import db, passkey
from ..util import passphrase from ..util import passphrase
from ..util.tokens import create_token, session_key
from .session import infodict from .session import infodict
@ -58,40 +54,6 @@ async def register_chat(
@app.websocket("/register") @app.websocket("/register")
@websocket_error_handler @websocket_error_handler
async def websocket_register_new(
ws: WebSocket, user_name: str = Query(""), auth=Cookie(None)
):
"""Register a new user and with a new passkey credential."""
origin = ws.headers["origin"]
user_uuid = uuid7.create()
# WebAuthn registration
credential = await register_chat(ws, user_uuid, user_name, origin=origin)
# Store the user and credential in the database
await db.instance.create_user_and_credential(
User(user_uuid, user_name, created_at=datetime.now()),
credential,
)
# Create a session token for the new user
token = create_token()
await db.instance.create_session(
user_uuid=user_uuid,
key=session_key(token),
expires=datetime.now() + EXPIRES,
info=infodict(ws, "authenticated"),
credential_uuid=credential.uuid,
)
await ws.send_json(
{
"user_uuid": str(user_uuid),
"session_token": token,
}
)
@app.websocket("/add_credential")
@websocket_error_handler
async def websocket_register_add(ws: WebSocket, auth=Cookie(None)): async def websocket_register_add(ws: WebSocket, auth=Cookie(None)):
"""Register a new credential for an existing user.""" """Register a new credential for an existing user."""
origin = ws.headers["origin"] origin = ws.headers["origin"]