Une nouvelle version du portail de gestion des comptes externes sera mise en production lundi 09 août. Elle permettra d'allonger la validité d'un compte externe jusqu'à 3 ans. Pour plus de détails sur cette version consulter : https://doc-si.inria.fr/x/FCeS

Commit 57df3228 authored by GARNIER Laurent's avatar GARNIER Laurent
Browse files

Merge branch 'deploy-tokens' into 'django'

add webapp deploy tokens

Closes allgo.inria.fr#5 and #44

See merge request !204
parents e5c0a544 f27e1d92
Pipeline #136770 failed with stages
in 1 second
......@@ -40,6 +40,8 @@ import http
import json
import logging
import os
import re
import sys
import time
import weakref
......@@ -465,6 +467,53 @@ class AllgoAio:
except Exception:
log.exception("error in the redis notification loop")
async def get_image_description(self, request, repo: str, manifest: bytes) -> str:
"""Get the description of an image in the registry
The WebappVersion.description string is provided by the user:
- in a UI form (if committing from a sandbox)
- in the 'allgo.description' label (if pushing a docker image)
'manifest' is the manifest of the image being pushed to the registry.
It points to a config blob which contains the image labels.
This function parses the manifest, then it downloads and parses the
config blob and finally returns the label if present.
To be future-proof (the manifest format may change in the future), the
function returns an empty string and logs a warning if it fails.
"""
def error(msg, *k, **kw):
log.warning("unable to get the allgo.description label from pushed image (%s)",
msg % k, **kw)
return ""
try:
js = json.loads(manifest.decode())
if js["schemaVersion"] != 2:
return error("unknown schemaVersion=%r" % js["schemaVersion"])
cfg = js["config"]
digest, size = cfg["digest"], cfg["size"]
if size > 1024**2:
return error("config blob too big (%d bytes)" % size)
if not re.fullmatch("[A-Za-z0-9:]+", digest):
return error("malformatted digest %r" % digest)
async with self.http_client.get("%s/v2/%s/blobs/%s" % (
config.env.ALLGO_REGISTRY_PRIVATE_URL, repo, digest),
headers=prepare_headers(request)) as cfg_reply:
if not is_ok(cfg_reply):
return error("unable to get the config blob (Error %d)" % cfg_reply.status)
js = json.loads(await cfg_reply.text())
try:
return str(js["config"]["Labels"]["allgo.description"])
except (TypeError, KeyError):
return ""
except Exception as e:
return error("unhandled exception", exc_info=sys.exc_info())
async def handle_image_manifest(self, request):
"""Registry endpoint for pushing/pulling image manifests
......@@ -491,14 +540,17 @@ class AllgoAio:
headers = prepare_headers(request)
headers["Content-Type"] = request.content_type
manifest = await request.read()
repo = request.match_info["repo"]
tag = request.match_info["tag"]
description = (await self.get_image_description(request, repo, manifest)
) if action == "push" else ""
# call django's pre hook
async with self.django_request("POST", "/jwt/pre-"+action,
headers=headers, params={"repo": repo, "tag": tag},
) as django_reply:
headers=headers, params={"repo": repo, "tag": tag,
"description": description}) as django_reply:
if not is_ok(django_reply):
return await forward_response(django_reply)
......@@ -508,7 +560,7 @@ class AllgoAio:
real_url = "%s/v2/%s/manifests/id%d" % (
config.env.ALLGO_REGISTRY_PRIVATE_URL, repo, version_id)
async with self.http_client.request(request.method, real_url,
headers=headers, data=await request.read()) as registry_reply:
headers=headers, data=manifest) as registry_reply:
if action == "pull":
# pull
......
......@@ -13,6 +13,7 @@ from .models import (
Job,
JobQueue,
Runner,
Token,
Webapp,
WebappParameter,
)
......@@ -211,3 +212,21 @@ class WebappImportForm(forms.Form):
webapp_id = forms.IntegerField(label="Webapp ID", required=False)
docker_name = forms.CharField(label="Short name", required=False,
validators=[docker_name_validator])
class WebappTokenForm(forms.ModelForm):
name = forms.CharField(help_text="An arbitrary name for this token")
lifetime = forms.ChoiceField(initial="7", required=False,
choices=[
("1", "1 day"),
("7", "1 week"),
("30", "1 month"),
("91", "3 months"),
("182", "6 months"),
("365", "1 year"),
("1095","3 years"),
("0", "unlimited"),
])
class Meta:
model = Token
fields = ("name", "expires_at")
from __future__ import unicode_literals
import datetime
import os
from django.conf import settings
from django.contrib import auth
from django.contrib.auth.models import User, AnonymousUser
from django.core.validators import MinValueValidator, RegexValidator
from django.core.validators import MinValueValidator, RegexValidator, ValidationError
from django.db import models
from django.db.models.signals import post_save
from django.dispatch import receiver
......@@ -263,7 +264,7 @@ class Webapp(TimeStampModel):
def is_pullable_by(self, actor, *, client_ip=None):
"""Return True if the given actor is allowed to pull an image of this webapp
`actor` may be a User, AllgoUser, Runner or None
`actor` may be a User, AllgoUser, Runner, Token or None
`client_ip` is client IP address (used for limiting admin/open_bar
access to the adresses listed in ALLGO_ALLOWED_IP_ADMIN)
......@@ -272,6 +273,9 @@ class Webapp(TimeStampModel):
and is_allowed_ip_admin(client_ip)):
return True
if isinstance(actor, Token):
# deploy tokens are just for pushing (for the moment)
return False
user = self._resolve_user(actor)
if user == self.user:
return True
......@@ -282,8 +286,10 @@ class Webapp(TimeStampModel):
def is_pushable_by(self, actor):
"""Return True if the given actor is allowed to push an image of this webapp
`actor` may be a User, AllgoUser, Runner or None
`actor` may be a User, AllgoUser, Runner, Token or None
"""
if isinstance(actor, Token):
return actor.webapp == self and actor.user is None
return self._resolve_user(actor) == self.user
......@@ -648,6 +654,86 @@ class UserAgreement(BaseModel):
db_table = 'dj_user_agreement'
class Token(BaseModel):
"""Authentication token model
Tokens are 64 bytes long and are made of:
- a 12-byte unique id
- a 52-byte secret
The secrets are stored as a digest (using a password hasher), therefore the plaintext is
displayed to the user at creation time and cannot be recovered aftefwards.
They may have an expiration date (if null, the token never expires).
For the moment, the tokens ae only for pushing a new WebappVersion with the docker registry API.
"""
id = models.CharField(max_length=12, primary_key=True,
validators=[RegexValidator(r'\A[a-zA-Z0-9]{12}\Z')])
secret = models.CharField(max_length=128)
name = models.CharField(max_length=128)
user = models.ForeignKey(User, null=True, blank=True, on_delete=models.CASCADE)
webapp = models.ForeignKey(Webapp, null=True, blank=True, on_delete=models.CASCADE)
created_at = models.DateTimeField(auto_now_add=True)
expires_at = models.DateField(null=True, blank=True)
_hasher = auth.hashers.PBKDF2PasswordHasher()
@classmethod
def generate(cls, **kw):
"""Generate a new token
The 'id' and 'secret' fields are automatically generated.
The extra '**kw' arguments provide the values for the other fields.
Return a tuple with:
- the newly created Token object
- a string containing the plain unencrypted token
"""
assert "id" not in kw
assert "secret" not in kw
secret = get_random_string(52)
kw["secret"] = cls._hasher.encode(secret, cls._hasher.salt())
# Collisions on the id are very unlikely. If this happens then we retry at most 10 times.
for _ in range(10):
try:
token = Token(id=get_random_string(12), **kw)
token.save()
return token, token.id+secret
except ValidationError as e:
if "id" not in e.error_dict:
raise
raise ValueError("too many collisions")
@classmethod
def authenticate(cls, raw_token: str):
"""Authenticate with a token
This function looks up the token in the db, verifies it and returns the relevant Token
object.
It returns None if the token is not valid (if not found, if expired or if the secret does
not match).
"""
token = Token.objects.filter(id=raw_token[:12]).first()
if ( (token is not None)
and ((token.expires_at is None) or (token.expires_at > datetime.date.today()))
and cls._hasher.verify(raw_token[12:], token.secret)):
return token
return None
class Meta:
db_table = 'dj_tokens'
verbose_name = 'allgo token'
verbose_name_plural = 'allgo tokens'
@receiver(post_save, sender=User)
def create_user_profile(sender, instance, created, **kwargs):
if created:
......
......@@ -56,6 +56,10 @@ urlpatterns = [
url(r'^apps/_import/$', views.WebappImport.as_view(), name='webapp_import'),
url(r'^apps/(?P<docker_name>[\w-]+)/import$', views.WebappVersionImport.as_view(),
name="webapp_version_import"),
url(r'^apps/(?P<docker_name>[\w-]+)/tokens$', views.WebappTokenCreate.as_view(),
name="webapp_token_create"),
url(r'^apps/(?P<docker_name>[\w-]+)/tokens/(?P<token>\w+)/delete$', views.WebappTokenDelete.as_view(),
name="webapp_token_delete"),
url(r'^apps/(?P<docker_name>[\w-]+)/update$', views.WebappUpdate.as_view(),
name="webapp_update"),
url(r'^apps/(?P<docker_name>[\w-]+)/sandbox$', views.WebappSandboxPanel.as_view(),
......
......@@ -20,6 +20,7 @@ Attributes:
# Python standard libraries
import datetime
import itertools
import logging
import os
......@@ -43,6 +44,7 @@ from django.contrib.messages.views import SuccessMessageMixin
from django.core.exceptions import ObjectDoesNotExist
from django.core.validators import ValidationError
from django.db import transaction
from django.db.models import Q
from django.http import HttpResponse, JsonResponse, HttpResponseRedirect, FileResponse, Http404
from django.shortcuts import get_object_or_404, redirect
from django.urls import reverse, reverse_lazy
......@@ -73,6 +75,7 @@ from .forms import (
RunnerForm,
WebappForm,
WebappImportForm,
WebappTokenForm,
)
from .helpers import (
get_base_url,
......@@ -94,6 +97,7 @@ from .models import (
Webapp,
WebappParameter,
WebappVersion,
Token,
Tos,
UserAgreement,
)
......@@ -629,7 +633,7 @@ class WebappSandboxPanel(UserAccessMixin, TemplateView):
"""
context = super().get_context_data(**kwargs)
context['webapp'] = self.get_object()
context['webapp'] = webapp = self.get_object()
context["ssh_command"] = "ssh%s %s@%s" % (
(" -p %s" % config.env.ALLGO_SSH_PORT
......@@ -647,6 +651,12 @@ class WebappSandboxPanel(UserAccessMixin, TemplateView):
webapp=context["webapp"], state=state))
context['versions'] = natsort.versorted(versions.values(), key=lambda v: v.number)
context['versions'].reverse()
# docker parameters
context['repository'] = "%s/%s" % (
get_base_url(self.request).split("//", 1)[1], webapp.docker_name)
return context
def post(self, request, *, docker_name): # pylint: disable=unused-variable
......@@ -755,6 +765,66 @@ class WebappSandboxPanel(UserAccessMixin, TemplateView):
# page.
return HttpResponseRedirect(request.path_info)
class WebappTokenCreate(UserAccessMixin, SuccessMessageMixin, CreateView):
"""Create a new webapp deploy token
This view lists the existing valid tokens for a given webapp and provides a form to create a new
token.
"""
model = Token
form_class = WebappTokenForm
template_name = "webapp_token_create.html"
def get_webapp(self):
"""Returns the webapp object according to its docker name or a 404 error"""
return get_object_or_404(Webapp, docker_name=self.kwargs["docker_name"],
user_id=self.request.user.id)
def get_context_data(self, **kw):
ctx=super().get_context_data(**kw)
ctx["webapp"] = webapp = self.get_webapp()
ctx["new_token"] = self.request.session.pop("new_webapp_token", None)
ctx["token_list"] = (Token.objects.filter(webapp=webapp, user=None)
.filter(Q(expires_at=None)|Q(expires_at__gt=datetime.date.today()))
.order_by("-created_at"))
ctx['repository'] = "%s/%s" % (
get_base_url(self.request).split("//", 1)[1], webapp.docker_name)
return ctx
def form_valid(self, form):
webapp = self.get_webapp()
lifetime = int(form.cleaned_data["lifetime"])
expires_at = (datetime.date.today() + datetime.timedelta(lifetime)) if lifetime else None
token, raw = Token.generate(webapp=webapp, name=form.cleaned_data["name"],
expires_at=expires_at)
messages.success(self.request, "Token created")
self.request.session["new_webapp_token"] = raw
return HttpResponseRedirect("")
class WebappTokenDelete(UserAccessMixin, View):
"""Delete a webapp deploy token
This view attemps to delete the given token and redirects back to the token creation view.
"""
success_message = "Token deleted"
failure_message = "Token not found"
def get_webapp(self):
"""Returns the webapp object according to its docker name or a 404 error"""
return get_object_or_404(Webapp, docker_name=self.kwargs["docker_name"],
user_id=self.request.user.id)
def post(self, request, docker_name, token):
if Token.objects.filter(id=token, webapp=self.get_webapp(), user=None).delete()[0]:
messages.success(request, "Token deleted")
else:
messages.error(request, "Token not found")
return HttpResponseRedirect(reverse("main:webapp_token_create", args=(docker_name,)))
# TAGS
# -----------------------------------------------------------------------------
......
......@@ -17,9 +17,7 @@
<div class="tab-content">
<ul class="nav nav-tabs">
<li class="nav-item"><a class="nav-link active" data-toggle="tab" href="#ssh"><i class="fas fa-terminal"></i> SSH</a></li>
{% comment Disabled until #227 is implemented %}
<li class="nav-item"><a class="nav-link" data-toggle="tab" href="#docker"><i class="fab fa-docker"></i> Docker</a></li>
{% endcomment %}
</ul>
<div class="tab-pane active" id="ssh">
......@@ -30,7 +28,7 @@
<ul>
<li>start a sandbox, connect to it via ssh, then install the
application manually</li>
<li><del>create a docker image and push it to allgo (see the Docker pane)</del> (not yet available)</li>
<li>create a docker image and push it to allgo (see the Docker pane)</li>
</ul>
</p>
......@@ -239,12 +237,26 @@
<p><i class="fas fa-question-circle"></i> Need help? <a href="#" title="A||go user documentation">report to the documentation</a>.</p>
</div>
{########### DOCKER ###########}
<div class="tab-pane" id="docker">
<p class="mt-3">You can as well directly push your docker image.</p>
<h5 class="mt-3">Push a docker image</h5>
Allgo supports the <a
href="https://docs.docker.com/registry/">Docker Registry</a> API.
You may package a new version of your application into a docker
image and push it to deploy it on Allgo.
<pre class="language-bash"><code class="language-*">docker login --username {{ user.get_username }} {{ request.get_host }}/{{ webapp.docker_name }}
docker build -t {{ webapp.docker_name }} .
docker push {{ request.get_host }}/{{ webapp.docker_name }}:&lt;version&gt;</code></pre>
<p class="mt-3">You will need to generate a secret token
authenticate with the registry.</p>
<a href="{% url "main:webapp_token_create" webapp.docker_name %}" class="btn btn-primary">Manage deploy tokens</a>
<pre class="language-bash mt-5"><code class="language-*"
># Build your image and tag it as '{{repository}}:VERSION'
docker build -t {{ repository }}:latest .
# Login on the allgo repository and push the '{{repository}}:VERSION' image
docker login -u token -p XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX {{ repository }}
docker push {{ repository }}:latest</code></pre>
</div>
</div>
</div>
......
{% extends "base.html" %}
{% load static converters htmlattrs humanize %}
{% block title %}{{ webapp.name | fancy_webapp_name | title }} tokens{% endblock %}
{% block breadcrumb %}
<li class="breadcrumb-item"><a href="{% url 'main:webapp_list' %}">Applications</a></li>
<li class="breadcrumb-item"><a href="{% url 'main:webapp_detail' webapp.docker_name %}">{{ webapp.name }}</a></li>
<li class="breadcrumb-item active" aria-current="page">Tokens</li>
{% endblock %}
{% block content %}
<div class="container">
<div class="allgo-page">
<h1>Deploy tokens</h1>
</div>
{% if new_token %}
<div class="card mb-5">
<div class="card-body">
<h5 class="card-title">New token</h5>
<div class="form-inline card-text">
<div class="mr-2">Your new token is:</div>
<div class="input-group flex-fill">
<input type="text" class="form-control" value="{{ new_token }}" readonly>
<div class="input-group-append">
<button
class="btn btn-primary js-copy"
data-toggle="tooltip"
data-copy="{{ new_token }}"
data-placement="top"
title="Copy to clipboard" type="button"><i class="fas fa-clipboard"></i><span class="text-hide">Copy to clipboard</span></button>
</div>
</div>
</div>
<p class="card-text text-danger mt-3">Make sure you save it, you won't be able to access it again.</p>
<p class="card-text">You may now login to the docker repository and push an image with the following command:</p>
<pre class="language-bash"><code class="language-*"
>docker login -u token -p {{ new_token }} {{ repository }}
docker push {{ repository }}:latest</code></pre>
</pre>
</div>
</div>
{% endif %}
<form method="post" class="mb-5">
{% csrf_token %}
<div class="form-row">
<div class="col-sm-6 col-lg-4">
<div class="form-group">
{{ form.name.label_tag }}
{% if form.name.errors %}
{{ form.name | add_class:"form-control is-invalid" }}
{% else %}
{{ form.name | add_class:"form-control"}}
{% endif %}
<small class="form-text text-muted">{{ form.name.help_text }}</small>
</div>
</div>
<div class="col-sm-3 col-lg-2">
<div class="form-group">
{{ form.lifetime.label_tag }}
{{ form.lifetime | add_class:"form-control" }}
<small class="form-text text-muted">{{ form.lifetime.help_text }}</small>
</div>
</div>
<div class="col-sm-3 col-lg-2">
<div class="form-group">
<label>&nbsp;</label>
<input class="btn btn-primary form-control" type="submit" value="Create token">
</div>
</div>
</div>
</form>
<table class="table table-striped">
<thead>
<tr>
<th scope="col">Token</th>
<th scope="col">Name</th>
<th scope="col">Created</th>
<th scope="col">Expires</th>
<th scope="col">Action</th>
</thead>
<tbody>
{% for token in token_list %}
<tr>
<th scope="row"><tt>{{token.id}}</tt></th>
<td>{{token.name}}</td>
<td>{{token.created_at.date}}</td>
<td>{{token.expires_at | default:"<i class='text-secondary'>never</i>"}}</td>
<td><form action="{%url 'main:webapp_token_delete' webapp.docker_name token.id %}" method="post">
{% csrf_token %}
<button type="submit" class="btn btn-danger"><i class="fas fa-trash-alt"></i></button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endblock %}
{% block messages %}
{{ block.super }}
{% include 'partials/_form_messages.html' %}
{% endblock %}
{% block javascript %}
{{ block.super }}
<script defer src="{% static 'js/tooltip.js' %}"></script>
{% endblock %}
......@@ -50,7 +50,7 @@ def kid_from_crypto_key(private_key_path, key_type):
return key_id_encode(algorithm.digest()[:30])
class Token(object):
class JwtToken(object):
def __init__(self, service, access_type="", access_name="", access_actions=None, subject=''):
if access_actions is None:
access_actions = []
......
......@@ -4,10 +4,10 @@ import logging
import config.env
from django.http import JsonResponse, HttpResponse
from django.views.decorators.csrf import csrf_exempt
from main.models import User, Runner, Webapp, WebappVersion
from main.models import User, Runner, Token, Webapp, WebappVersion
from main.helpers import is_allowed_ip_admin
from .tokens import Token
from .tokens import JwtToken
log = logging.getLogger('jwt')
......@@ -46,25 +46,28 @@ def pre_pushpull(request, action):
repo = request.GET["repo"]
tag = request.GET["tag"]
description = request.GET["description"]
try:
# find the relevant webapp
webapp = Webapp.objects.get(docker_name=repo)
except Webapp.DoesNotExist:
return JsonResponse({"error": "unknown repository"}, status=404)
return JsonResponse({"errors": [
{"code": "NAME_INVALID", "message": "unknown repository"}]}, status=404)
if action == "pull":
# find the id of the WebappVersion to be pulled
version = WebappVersion.objects.filter(webapp=webapp, number=tag,
state=WebappVersion.READY).order_by("-id").first()
if version is None:
return JsonResponse({"error": "unknown tag"}, status=404)
return JsonResponse({"errors": [
{"code": "TAG_INVALID", "message": "unknown tag"}]}, status=404)
elif action == "push":
# create a new WebappVersion entry in state USER
version = WebappVersion(
webapp=webapp, number=tag, state=WebappVersion.USER,
published=True, description="TODO")
published=True, description=description)
version.save()
else:
......@@ -170,28 +173,30 @@ def jwt_auth(request):
return HttpResponse(status=401)
username, password = base64.b64decode(credentials).decode('utf-8').split(':', 1)
#log.debug('HTTP_AUTHORIZATION %s username %s', auth_header, username)
if username == "$token":
if username == "token":
if len(password) < MIN_TOKEN_SIZE: