Skip to content

Do not merge/hackathon 2025 #968

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 15 commits into
base: main
Choose a base branch
from
Draft
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
41 changes: 27 additions & 14 deletions .github/workflows/docker-hub.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,7 @@ on:
workflow_dispatch:
push:
branches:
- 'main'
tags:
- 'v*'
pull_request:
branches:
- 'main'
- 'ci/trivy-fails'
- 'do-not-merge/hackathon-2025'

env:
DOCKER_USER: 1001:127
Expand All @@ -31,7 +25,6 @@ jobs:
images: lasuite/impress-backend
-
name: Login to DockerHub
if: github.event_name != 'pull_request'
run: echo "${{ secrets.DOCKER_HUB_PASSWORD }}" | docker login -u "${{ secrets.DOCKER_HUB_USER }}" --password-stdin
-
name: Run trivy scan
Expand All @@ -43,10 +36,10 @@ jobs:
name: Build and push
uses: docker/build-push-action@v6
with:
push: true
context: .
target: backend-production
build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}

Expand All @@ -64,7 +57,6 @@ jobs:
images: lasuite/impress-frontend
-
name: Login to DockerHub
if: github.event_name != 'pull_request'
run: echo "${{ secrets.DOCKER_HUB_PASSWORD }}" | docker login -u "${{ secrets.DOCKER_HUB_USER }}" --password-stdin
-
name: Run trivy scan
Expand All @@ -76,13 +68,13 @@ jobs:
name: Build and push
uses: docker/build-push-action@v6
with:
push: true
context: .
file: ./src/frontend/Dockerfile
target: frontend-production
build-args: |
DOCKER_USER=${{ env.DOCKER_USER }}:-1000
PUBLISH_AS_MIT=false
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}

Expand All @@ -100,7 +92,6 @@ jobs:
images: lasuite/impress-y-provider
-
name: Login to DockerHub
if: github.event_name != 'pull_request'
run: echo "${{ secrets.DOCKER_HUB_PASSWORD }}" | docker login -u "${{ secrets.DOCKER_HUB_USER }}" --password-stdin
-
name: Run trivy scan
Expand All @@ -112,11 +103,34 @@ jobs:
name: Build and push
uses: docker/build-push-action@v6
with:
push: true
context: .
file: ./src/frontend/servers/y-provider/Dockerfile
target: y-provider
build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}

build-and-push-mcp-server:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: lasuite/impress-mcp-server
- name: Login to DockerHub
run: echo "${{ secrets.DOCKER_HUB_PASSWORD }}" | docker login -u "${{ secrets.DOCKER_HUB_USER }}" --password-stdin
- name: Build and push
uses: docker/build-push-action@v6
with:
push: true
context: ./src/mcp_server
file: ./src/mcp_server/Dockerfile
build-args: |
DOCKER_USER=${{ env.DOCKER_USER }}:-1000
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}

Expand All @@ -125,7 +139,6 @@ jobs:
- build-and-push-frontend
- build-and-push-backend
runs-on: ubuntu-latest
if: github.event_name != 'pull_request'
steps:
- uses: numerique-gouv/action-argocd-webhook-notification@main
id: notify
Expand Down
11 changes: 10 additions & 1 deletion bin/Tiltfile
Original file line number Diff line number Diff line change
Expand Up @@ -39,10 +39,19 @@ docker_build(
]
)

docker_build(
'localhost:5001/impress-mcp-server:latest',
context='../src/mcp_server',
dockerfile='../src/mcp_server/Dockerfile',
)

k8s_resource('impress-docs-backend-migrate', resource_deps=['postgres-postgresql'])
k8s_resource('impress-docs-backend-createsuperuser', resource_deps=['impress-docs-backend-migrate'])
k8s_resource('impress-docs-backend', resource_deps=['impress-docs-backend-migrate'])
k8s_yaml(local('cd ../src/helm && helmfile -n impress -e dev template .'))

# helmfile in docker mount the current working directory and the helmfile.yaml
# requires the keycloak config in another directory
k8s_yaml(local('cd .. && helmfile -n impress -e ${DEV_ENV:-dev} template --file ./src/helm/helmfile.yaml'))

migration = '''
set -eu
Expand Down
35 changes: 29 additions & 6 deletions src/backend/core/api/viewsets.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,13 +25,15 @@
import requests
import rest_framework as drf
from botocore.exceptions import ClientError
from knox.auth import TokenAuthentication
from lasuite.malware_detection import malware_detection
from lasuite.oidc_resource_server.authentication import ResourceServerAuthentication
from rest_framework import filters, status, viewsets
from rest_framework import response as drf_response
from rest_framework.permissions import AllowAny
from rest_framework.throttling import UserRateThrottle

from core import authentication, enums, models
from core import authentication, enums, models, utils as core_utils
from core.services.ai_services import AIService
from core.services.collaboration_services import CollaborationService
from core.utils import extract_attachments, filter_descendants
Expand Down Expand Up @@ -430,9 +432,7 @@ class DocumentViewSet(
ordering = ["-updated_at"]
ordering_fields = ["created_at", "updated_at", "title"]
pagination_class = Pagination
permission_classes = [
permissions.DocumentAccessPermission,
]
permission_classes = [permissions.DocumentAccessPermission]
queryset = models.Document.objects.all()
serializer_class = serializers.DocumentSerializer
ai_translate_serializer_class = serializers.AITranslateSerializer
Expand Down Expand Up @@ -669,10 +669,14 @@ def trashbin(self, request, *args, **kwargs):
return self.get_response_for_queryset(queryset)

@drf.decorators.action(
authentication_classes=[authentication.ServerToServerAuthentication],
authentication_classes=[
authentication.ServerToServerAuthentication,
ResourceServerAuthentication,
TokenAuthentication,
],
detail=False,
methods=["post"],
permission_classes=[],
permission_classes=[permissions.IsAuthenticated],
url_path="create-for-owner",
)
@transaction.atomic
Expand Down Expand Up @@ -1349,6 +1353,25 @@ def media_check(self, request, *args, **kwargs):
}

return drf.response.Response(body, status=drf.status.HTTP_200_OK)

@drf.decorators.action(detail=True, methods=["get"], url_path="content")
def content(self, request, *args, **kwargs):
"""
Get the content of a document
"""

document = self.get_object()

# content_type = response.headers.get("Content-Type", "")

base64_yjs_content = document.content
content = core_utils.base64_yjs_to_markdown(base64_yjs_content)

body = {
"content": content,
}

return drf.response.Response(body, status=drf.status.HTTP_200_OK)

@drf.decorators.action(
detail=True,
Expand Down
18 changes: 15 additions & 3 deletions src/backend/core/authentication/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,15 @@
from rest_framework.exceptions import AuthenticationFailed


class AuthenticatedServer:
"""
Simple class to represent an authenticated server to be used along the
IsAuthenticated permission.
"""

is_authenticated = True


class ServerToServerAuthentication(BaseAuthentication):
"""
Custom authentication class for server-to-server requests.
Expand Down Expand Up @@ -39,13 +48,16 @@ def authenticate(self, request):
# Validate token format and existence
auth_parts = auth_header.split(" ")
if len(auth_parts) != 2 or auth_parts[0] != self.TOKEN_TYPE:
raise AuthenticationFailed("Invalid authorization header.")
# Do not raise here to leave the door open for other authentication methods
return None

token = auth_parts[1]
if token not in settings.SERVER_TO_SERVER_API_TOKENS:
raise AuthenticationFailed("Invalid server-to-server token.")
# Do not raise here to leave the door open for other authentication methods
return None

# Authentication is successful, but no user is authenticated
# Authentication is successful
return AuthenticatedServer(), token

def authenticate_header(self, request):
"""Return the WWW-Authenticate header value."""
Expand Down
1 change: 1 addition & 0 deletions src/backend/core/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -839,6 +839,7 @@ def get_abilities(self, user, ancestors_links=None):
"children_list": can_get,
"children_create": can_update and user.is_authenticated,
"collaboration_auth": can_get,
"content": can_get,
"cors_proxy": can_get,
"descendants": can_get,
"destroy": is_owner,
Expand Down
131 changes: 131 additions & 0 deletions src/backend/core/tests/test_user_token_api.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
"""
Test user_token API endpoints in the impress core app.
"""

import pytest
from knox.models import get_token_model
from rest_framework.test import APIClient

from core import factories, models

pytestmark = pytest.mark.django_db
AuthToken = get_token_model()

def test_api_user_token_list_anonymous(client):
"""Anonymous users should not be allowed to list user tokens."""
response = client.get("/api/v1.0/user-tokens/")
assert response.status_code == 403
assert response.json() == {
"detail": "Authentication credentials were not provided."
}


def test_api_user_token_list_authenticated(client):
"""
Authenticated users should be able to list their own tokens.
Tokens are identified by digest, and include created/expiry.
"""
user = factories.UserFactory()
# Knox creates a token instance and a character string token key.
# The create method returns a tuple: (instance, token_key_string)
token_instance_1, _ = AuthToken.objects.create(user=user)
AuthToken.objects.create(user=user) # Another token for the same user
AuthToken.objects.create(user=factories.UserFactory()) # Token for a different user

client.force_login(user)

response = client.get("/api/v1.0/user-tokens/")
assert response.status_code == 200
content = response.json()
assert len(content) == 2

# Check that the response contains the digests of the tokens created for the user
response_token_digests = {item["digest"] for item in content}
assert token_instance_1.digest in response_token_digests

# Ensure the token_key is not listed
for item in content:
assert "token_key" not in item
assert "digest" in item
assert "created" in item
assert "expiry" in item


def test_api_user_token_create_anonymous(client):
"""Anonymous users should not be allowed to create user tokens."""
# The create endpoint does not take any parameters as per TokenCreateSerializer
# (user is implicit, other fields are read_only)
response = client.post("/api/v1.0/user-tokens/", data={})
assert response.status_code == 403
assert response.json() == {
"detail": "Authentication credentials were not provided."
}


def test_api_user_token_create_authenticated(client):
"""
Authenticated users should be able to create a new token.
The token key should be returned in the response upon creation.
"""
user = factories.UserFactory()

client.force_login(user)

# The create endpoint does not take any parameters as per TokenCreateSerializer
response = client.post("/api/v1.0/user-tokens/", data={})
assert response.status_code == 201
content = response.json()

# Based on TokenCreateSerializer, these fields should be in the response
assert "token_key" in content
assert "digest" in content
assert "created" in content
assert "expiry" in content
assert len(content["token_key"]) > 0 # Knox token key should be non-empty

# Verify the token was actually created in the database for the user
assert AuthToken.objects.filter(user=user, digest=content["digest"]).exists()

def test_api_user_token_destroy_anonymous(client):
"""Anonymous users should not be allowed to delete user tokens."""
user = factories.UserFactory()
token_instance, _ = AuthToken.objects.create(user=user)
response = client.delete(f"/api/v1.0/user-tokens/{token_instance.digest}/")
assert response.status_code == 403
assert AuthToken.objects.filter(digest=token_instance.digest).exists()


def test_api_user_token_destroy_authenticated_own_token(client):
"""Authenticated users should be able to delete their own tokens."""
user = factories.UserFactory()
token_instance, _ = AuthToken.objects.create(user=user)

client.force_login(user)

response = client.delete(f"/api/v1.0/user-tokens/{token_instance.digest}/")
assert response.status_code == 204
assert not AuthToken.objects.filter(digest=token_instance.digest).exists()


def test_api_user_token_destroy_authenticated_other_user_token(client):
"""Authenticated users should not be able to delete other users' tokens."""
user = factories.UserFactory()
other_user = factories.UserFactory()
other_user_token_instance, _ = AuthToken.objects.create(user=other_user)

client.force_login(user) # Log in as 'user'

response = client.delete(f"/api/v1.0/user-tokens/{other_user_token_instance.digest}/")
# The default behavior for a non-found or non-permissioned item in DestroyModelMixin
# when the queryset is filtered (as in get_queryset) is often a 404.
assert response.status_code == 404
assert AuthToken.objects.filter(digest=other_user_token_instance.digest).exists()


def test_api_user_token_destroy_non_existent_token(client):
"""Attempting to delete a non-existent token should result in a 404."""
user = factories.UserFactory()
client.force_login(user)

response = client.delete("/api/v1.0/user-tokens/nonexistentdigest/")
assert response.status_code == 404
Loading
Loading