Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 32 additions & 2 deletions api-server/app/auth/controllers/create_token.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import jwt
from datetime import datetime, timedelta
from starlette.responses import JSONResponse
from bson import ObjectId

from ..models.token_request import TokenRequest
from ..models.token_response import TokenResponse
Expand All @@ -10,6 +11,8 @@
from app.singletons.logs_manager import LogsManager

from app.user.models.user_database_model import User
from app.project.models.project_database_model import Project


logger = LogsManager().get_logger()

Expand Down Expand Up @@ -37,17 +40,44 @@ async def create_token(request: TokenRequest, x_exosphere_request_id: str) -> To

logger.info("User found and credential verified", x_exosphere_request_id=x_exosphere_request_id)

logger.info("User is a super admin", x_exosphere_request_id=x_exosphere_request_id)

previlage = None

if request.project:

project = await Project.get(ObjectId(request.project))

if not project:
logger.error("Project not found", x_exosphere_request_id=x_exosphere_request_id)
return JSONResponse(status_code=404, content={"success": False, "detail": "Project not found"})

logger.info("Project found", x_exosphere_request_id=x_exosphere_request_id)

if project.super_admin.ref.id == user.id:
previlage = "super_admin"

for user in project.users:
if user.user.ref.id == user.id:
previlage = user.permission.value
break

if not previlage:
logger.error("User does not have access to the project", x_exosphere_request_id=x_exosphere_request_id)
return JSONResponse(status_code=403, content={"success": False, "detail": "User does not have access to the project"})


token_claims = TokenClaims(
user_id=str(user.id),
user_name=user.name,
user_type=user.type,
verification_status=user.verification_status,
status=user.status,
project=request.project,
previlage=previlage,
satellites=request.satellites,
exp=int((datetime.now() + timedelta(seconds=JWT_EXPIRES_IN)).timestamp())
)


return TokenResponse(
access_token=jwt.encode(token_claims.model_dump(), JWT_SECRET_KEY, algorithm=JWT_ALGORITHM)
)
Expand Down
4 changes: 4 additions & 0 deletions api-server/app/auth/models/token_claims.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
from pydantic import BaseModel
from typing import Optional

class TokenClaims(BaseModel):
user_id: str
user_name: str
user_type: str
verification_status: str
status: str
project: Optional[str] = None
previlage: Optional[str] = None
satellites: Optional[list[str]] = None
exp: int
8 changes: 7 additions & 1 deletion api-server/app/auth/models/token_request.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
from pydantic import BaseModel, Field
from typing import Optional

class TokenRequest(BaseModel):
identifier: str = Field(..., description="Identifier of the user, could be an email, phone, username, etc.")
credential: str = Field(..., description="Credential of the user, could be a password, api secret, etc.")

credential: str = Field(..., description="Credential of the user, could be a password, api secret, etc.")

project: Optional[str] = Field(None, description="Project id against which the token is being requested.")

satellites: Optional[list[str]] = Field(None, description="Satellites against which the token is being requested.")
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import jwt
import os
import time
from typing import Optional

from app.singletons.logs_manager import LogsManager

from .models.token_claims import TokenClaims
from ..models.token_claims import TokenClaims

logger = LogsManager().get_logger()

Expand All @@ -14,7 +15,7 @@
JWT_ALGORITHM = "HS256"


async def get_token_claims(token: str, x_exosphere_request_id: str):
async def get_token_claims(token: str, x_exosphere_request_id: str) -> Optional[TokenClaims]:
try:
claims = TokenClaims(**jwt.decode(token, JWT_SECRET_KEY, algorithms=[JWT_ALGORITHM]))
logger.info("Token claims decoded", x_exosphere_request_id=x_exosphere_request_id, user_id=claims.user_id)
Expand Down
5 changes: 4 additions & 1 deletion api-server/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,13 @@
# injecting databases
from .user.models.user_database_model import User
from .project.models.project_database_model import Project
from .satellite.models.satellite_database_model import Satellite

# injecting routers
from .user.routes import router as user_router
from .auth.routes import router as auth_router
from .project.routes import router as project_router
from .satellite.routes import router as satellite_router

load_dotenv()

Expand All @@ -39,7 +41,7 @@ async def lifespan(app: FastAPI):
# initializing beanie
client = AsyncIOMotorClient(os.getenv("MONGO_URI"))
db = client[os.getenv("MONGO_DATABASE_NAME")]
await init_beanie(db, document_models=[User, Project])
await init_beanie(db, document_models=[User, Project, Satellite])
logger.info("beanie dbs initialized")

# main logic of the server
Expand Down Expand Up @@ -80,3 +82,4 @@ def health() -> dict:
app.include_router(user_router)
app.include_router(auth_router)
app.include_router(project_router)
app.include_router(satellite_router)
2 changes: 1 addition & 1 deletion api-server/app/project/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
from .models.create_project_request import CreateProjectRequest
from .models.create_project_response import CreateProjectResponse

from app.auth.get_token_claims import get_token_claims
from app.auth.services.get_token_claims import get_token_claims

router = APIRouter(prefix="/v0/project", tags=["project"])

Expand Down
62 changes: 62 additions & 0 deletions api-server/app/satellite/controllers/register_satellite.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
from beanie import Link
from bson import ObjectId
from pymongo.errors import DuplicateKeyError
from fastapi.responses import JSONResponse

from ..models.register_satellite_request import RegisterSatelliteRequest
from ..models.register_satellite_response import RegisterSatelliteResponse
from ..models.satellite_database_model import Satellite

from app.auth.models.token_claims import TokenClaims
from app.project.models.project_database_model import Project
from app.singletons.logs_manager import LogsManager

logger = LogsManager().get_logger()


async def register_satellite(request: RegisterSatelliteRequest, project_id: str, claims: TokenClaims, x_exosphere_request_id: str) -> RegisterSatelliteResponse:

try:
logger.info("Registering satellite", x_exosphere_request_id=x_exosphere_request_id, project_id=project_id, name=request.name)

satellite = Satellite(
name=request.name,
friendly_name=request.friendly_name,
description=request.description,
access_type=request.access_type,
configs=request.configs,
inputs=request.inputs,
metrics=request.metrics,
outputs=request.outputs,
project=Link(ObjectId(project_id), Project),
project_name=project_id,
image_uri=request.image_uri,
timeout=request.timeout
)
await satellite.insert()
logger.info("Satellite registered", x_exosphere_request_id=x_exosphere_request_id, project_id=project_id, name=request.name)

return RegisterSatelliteResponse(
id=str(satellite.id),
name=satellite.name,
friendly_name=satellite.friendly_name,
description=satellite.description,
access_type=satellite.access_type,
configs=satellite.configs,
inputs=satellite.inputs,
metrics=satellite.metrics,
outputs=satellite.outputs,
project=project_id,
project_name=satellite.project_name,
image_uri=satellite.image_uri,
timeout=satellite.timeout,
created_at=satellite.created_at,
updated_at=satellite.updated_at
)
except DuplicateKeyError as e:
logger.error("Error registering satellite", x_exosphere_request_id=x_exosphere_request_id, project_id=project_id, name=request.name, error=e)
return JSONResponse(status_code=400, content={"message": "Satellite already exists", "success": False})

except Exception as e:
logger.error("Error registering satellite", x_exosphere_request_id=x_exosphere_request_id, project_id=project_id, name=request.name, error=e)
raise e
5 changes: 5 additions & 0 deletions api-server/app/satellite/models/access_types.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
from enum import Enum

class AccessTypeEnum(str, Enum):
PUBLIC = "PUBLIC"
PRIVATE = "PRIVATE"
15 changes: 15 additions & 0 deletions api-server/app/satellite/models/register_satellite_request.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
from pydantic import BaseModel
from .access_types import AccessTypeEnum
from typing import Any, Optional

class RegisterSatelliteRequest(BaseModel):
name: str
friendly_name: str
description: str
access_type: AccessTypeEnum
image_uri: Optional[str] = None
timeout: Optional[int] = None
configs: dict[str, Any]
inputs: dict[str, Any]
metrics: dict[str, Any]
outputs: dict[str, Any]
22 changes: 22 additions & 0 deletions api-server/app/satellite/models/register_satellite_response.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
from pydantic import BaseModel
from .access_types import AccessTypeEnum
from typing import Any, Optional
from datetime import datetime


class RegisterSatelliteResponse(BaseModel):
id: str
name: str
friendly_name: str
description: str
access_type: AccessTypeEnum
configs: dict[str, Any]
inputs: dict[str, Any]
metrics: dict[str, Any]
outputs: dict[str, Any]
project: str
project_name: str
created_at: datetime
updated_at: datetime
image_uri: Optional[str] = None
timeout: Optional[int] = None
86 changes: 86 additions & 0 deletions api-server/app/satellite/models/satellite_database_model.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import jsonschema

from beanie import Document, before_event, Replace, Save, Link
from datetime import datetime
from pydantic import Field, field_validator
from .access_types import AccessTypeEnum
from docker_image import reference
from typing import Optional, Any
from pymongo import IndexModel, ASCENDING

from app.project.models.project_database_model import Project


class Satellite(Document):

name: str = Field(..., description="Name of the satellite")

friendly_name: str = Field(..., description="Friendly name of the satellite")

description: str = Field(..., description="Description of the satellite")

access_type: AccessTypeEnum = Field(..., description="Access type of the satellite")

configs: dict[str, Any] = Field(..., description="Configurations of the satellite, a valid jsonschema object for the configs")

inputs: dict[str, Any] = Field(..., description="Input data fothe satellite, a valid jsonschema object for the inputs")

metrics: dict[str, Any] = Field(..., description="Metrics of the satellite, a valid jsonschema object for the metrics")

outputs: dict[str, Any] = Field(..., description="Outputs of the satellite, a valid jsonschema object for the outputs")

project: Link[Project] = Field(..., description="Project of the satellite")

project_name: str = Field(..., description="Name of the project of the satellite")

created_at: datetime = Field(default_factory=datetime.now, description="Date and time when the satellite was created")

updated_at: datetime = Field(default_factory=datetime.now, description="Date and time when the satellite was last updated")

image_uri: Optional[str] = Field(None, description="OCI/Docker image URI for the satellite, if not provided autoscalling and dynamic scaling would not be available")

timeout: Optional[int] = Field(None, description="Timeout of the satellite in seconds")

@field_validator("configs", "inputs", "metrics", "outputs")
def validate_jsonschema(cls, v: dict[str, Any]) -> dict[str, Any]:
validator = jsonschema.validators.validator_for(v)

try:
validator.check_schema(v)
except jsonschema.exceptions.SchemaError as e:
raise ValueError(f"Invalid JSON schema: {e.message}")

return v

@field_validator("image_uri")
def validate_image_uri(cls, v: str) -> str:
if not v:
return v
try:
reference.Reference.parse(v)
except Exception as e:
raise ValueError(f"Invalid image URI: {e.message}")
return v

@field_validator("name")
def validate_name(cls, v: str) -> str:

not_allowed_chars = ["/", ".", " "]

if not v:
raise ValueError("Name cannot be empty")
if len(v) > 100:
raise ValueError("Name cannot be longer than 100 characters")
if any(char in not_allowed_chars for char in v):
raise ValueError("Name cannot contain the following characters: /, ., or whitespace")
return v

class Settings:
name = "Satellites"
indexes = [
IndexModel([("name", ASCENDING), ("project", ASCENDING)], unique=True)
]

@before_event([Save, Replace])
def update_updated_at(self):
self.updated_at = datetime.now()
27 changes: 27 additions & 0 deletions api-server/app/satellite/routes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
from fastapi import APIRouter, status, Request, Depends
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from typing import Annotated
from fastapi.responses import JSONResponse

from app.auth.services.get_token_claims import get_token_claims
from .controllers.register_satellite import register_satellite

from .models.register_satellite_request import RegisterSatelliteRequest
from .models.register_satellite_response import RegisterSatelliteResponse

router = APIRouter(prefix="/v0/project/{project_id}/satellite", tags=["satellite"])

@router.post(
"/",
response_model=RegisterSatelliteResponse,
status_code=status.HTTP_201_CREATED,
response_description="Satellite registered successfully"
)
async def register_satellite_route(project_id: str, body: RegisterSatelliteRequest, request: Request, token: Annotated[HTTPAuthorizationCredentials, Depends(HTTPBearer())]):
x_exosphere_request_id = getattr(request.state, "x_exosphere_request_id", None)
claims = await get_token_claims(token.credentials, x_exosphere_request_id)

if claims is None or claims.project != project_id:
return JSONResponse(status_code=401, content={"message": "Invalid token", "success": False})

return await register_satellite(body, project_id, claims, x_exosphere_request_id)
2 changes: 2 additions & 0 deletions api-server/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ requires-python = ">=3.12"
dependencies = [
"bcrypt>=4.3.0",
"beanie>=1.30.0",
"docker-image-py>=0.1.13",
"fastapi>=0.115.14",
"jsonschema>=4.24.0",
"pyjwt>=2.10.1",
"python-dotenv>=1.1.1",
"structlog>=25.4.0",
Expand Down
Loading