blueprints: internal storage (#4397)
* rework oci client Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org> * add blueprint content Signed-off-by: Jens Langhammer <jens@goauthentik.io> * add UI Signed-off-by: Jens Langhammer <jens@goauthentik.io> * make path optional Signed-off-by: Jens Langhammer <jens@goauthentik.io> * add validation Signed-off-by: Jens Langhammer <jens@goauthentik.io> * add tests Signed-off-by: Jens Langhammer <jens@goauthentik.io> * fix Signed-off-by: Jens Langhammer <jens@goauthentik.io> Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org> Signed-off-by: Jens Langhammer <jens@goauthentik.io>
This commit is contained in:
parent
f2961cb536
commit
1ed24a5eef
|
@ -1,4 +1,5 @@
|
||||||
"""Serializer mixin for managed models"""
|
"""Serializer mixin for managed models"""
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
from drf_spectacular.utils import extend_schema, inline_serializer
|
from drf_spectacular.utils import extend_schema, inline_serializer
|
||||||
from rest_framework.decorators import action
|
from rest_framework.decorators import action
|
||||||
from rest_framework.exceptions import ValidationError
|
from rest_framework.exceptions import ValidationError
|
||||||
|
@ -11,6 +12,7 @@ from rest_framework.viewsets import ModelViewSet
|
||||||
|
|
||||||
from authentik.api.decorators import permission_required
|
from authentik.api.decorators import permission_required
|
||||||
from authentik.blueprints.models import BlueprintInstance, BlueprintRetrievalFailed
|
from authentik.blueprints.models import BlueprintInstance, BlueprintRetrievalFailed
|
||||||
|
from authentik.blueprints.v1.importer import Importer
|
||||||
from authentik.blueprints.v1.tasks import apply_blueprint, blueprints_find_dict
|
from authentik.blueprints.v1.tasks import apply_blueprint, blueprints_find_dict
|
||||||
from authentik.core.api.used_by import UsedByMixin
|
from authentik.core.api.used_by import UsedByMixin
|
||||||
from authentik.core.api.utils import PassiveSerializer
|
from authentik.core.api.utils import PassiveSerializer
|
||||||
|
@ -40,6 +42,21 @@ class BlueprintInstanceSerializer(ModelSerializer):
|
||||||
raise ValidationError(exc) from exc
|
raise ValidationError(exc) from exc
|
||||||
return path
|
return path
|
||||||
|
|
||||||
|
def validate_content(self, content: str) -> str:
|
||||||
|
"""Ensure content (if set) is a valid blueprint"""
|
||||||
|
if content == "":
|
||||||
|
return content
|
||||||
|
context = self.instance.context if self.instance else {}
|
||||||
|
valid, logs = Importer(content, context).validate()
|
||||||
|
if not valid:
|
||||||
|
raise ValidationError(_("Failed to validate blueprint"), *[x["msg"] for x in logs])
|
||||||
|
return content
|
||||||
|
|
||||||
|
def validate(self, attrs: dict) -> dict:
|
||||||
|
if attrs.get("path", "") == "" and attrs.get("content", "") == "":
|
||||||
|
raise ValidationError(_("Either path or content must be set."))
|
||||||
|
return super().validate(attrs)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
|
|
||||||
model = BlueprintInstance
|
model = BlueprintInstance
|
||||||
|
@ -54,6 +71,7 @@ class BlueprintInstanceSerializer(ModelSerializer):
|
||||||
"enabled",
|
"enabled",
|
||||||
"managed_models",
|
"managed_models",
|
||||||
"metadata",
|
"metadata",
|
||||||
|
"content",
|
||||||
]
|
]
|
||||||
extra_kwargs = {
|
extra_kwargs = {
|
||||||
"status": {"read_only": True},
|
"status": {"read_only": True},
|
||||||
|
|
|
@ -0,0 +1,23 @@
|
||||||
|
# Generated by Django 4.1.5 on 2023-01-10 19:48
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("authentik_blueprints", "0001_initial"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="blueprintinstance",
|
||||||
|
name="content",
|
||||||
|
field=models.TextField(blank=True, default=""),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="blueprintinstance",
|
||||||
|
name="path",
|
||||||
|
field=models.TextField(blank=True, default=""),
|
||||||
|
),
|
||||||
|
]
|
|
@ -1,30 +1,18 @@
|
||||||
"""blueprint models"""
|
"""blueprint models"""
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from urllib.parse import urlparse
|
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
from django.contrib.postgres.fields import ArrayField
|
from django.contrib.postgres.fields import ArrayField
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from opencontainers.distribution.reggie import (
|
|
||||||
NewClient,
|
|
||||||
WithDebug,
|
|
||||||
WithDefaultName,
|
|
||||||
WithDigest,
|
|
||||||
WithReference,
|
|
||||||
WithUserAgent,
|
|
||||||
WithUsernamePassword,
|
|
||||||
)
|
|
||||||
from requests.exceptions import RequestException
|
|
||||||
from rest_framework.serializers import Serializer
|
from rest_framework.serializers import Serializer
|
||||||
from structlog import get_logger
|
from structlog import get_logger
|
||||||
|
|
||||||
|
from authentik.blueprints.v1.oci import BlueprintOCIClient, OCIException
|
||||||
from authentik.lib.config import CONFIG
|
from authentik.lib.config import CONFIG
|
||||||
from authentik.lib.models import CreatedUpdatedModel, SerializerModel
|
from authentik.lib.models import CreatedUpdatedModel, SerializerModel
|
||||||
from authentik.lib.sentry import SentryIgnoredException
|
from authentik.lib.sentry import SentryIgnoredException
|
||||||
from authentik.lib.utils.http import authentik_user_agent
|
|
||||||
|
|
||||||
OCI_MEDIA_TYPE = "application/vnd.goauthentik.blueprint.v1+yaml"
|
|
||||||
LOGGER = get_logger()
|
LOGGER = get_logger()
|
||||||
|
|
||||||
|
|
||||||
|
@ -74,7 +62,8 @@ class BlueprintInstance(SerializerModel, ManagedModel, CreatedUpdatedModel):
|
||||||
|
|
||||||
name = models.TextField()
|
name = models.TextField()
|
||||||
metadata = models.JSONField(default=dict)
|
metadata = models.JSONField(default=dict)
|
||||||
path = models.TextField()
|
path = models.TextField(default="", blank=True)
|
||||||
|
content = models.TextField(default="", blank=True)
|
||||||
context = models.JSONField(default=dict)
|
context = models.JSONField(default=dict)
|
||||||
last_applied = models.DateTimeField(auto_now=True)
|
last_applied = models.DateTimeField(auto_now=True)
|
||||||
last_applied_hash = models.TextField()
|
last_applied_hash = models.TextField()
|
||||||
|
@ -86,60 +75,29 @@ class BlueprintInstance(SerializerModel, ManagedModel, CreatedUpdatedModel):
|
||||||
|
|
||||||
def retrieve_oci(self) -> str:
|
def retrieve_oci(self) -> str:
|
||||||
"""Get blueprint from an OCI registry"""
|
"""Get blueprint from an OCI registry"""
|
||||||
url = urlparse(self.path)
|
client = BlueprintOCIClient(self.path.replace("oci://", "https://"))
|
||||||
ref = "latest"
|
|
||||||
path = url.path[1:]
|
|
||||||
if ":" in url.path:
|
|
||||||
path, _, ref = path.partition(":")
|
|
||||||
client = NewClient(
|
|
||||||
f"https://{url.hostname}",
|
|
||||||
WithUserAgent(authentik_user_agent()),
|
|
||||||
WithUsernamePassword(url.username, url.password),
|
|
||||||
WithDefaultName(path),
|
|
||||||
WithDebug(True),
|
|
||||||
)
|
|
||||||
LOGGER.info("Fetching OCI manifests for blueprint", instance=self)
|
|
||||||
manifest_request = client.NewRequest(
|
|
||||||
"GET",
|
|
||||||
"/v2/<name>/manifests/<reference>",
|
|
||||||
WithReference(ref),
|
|
||||||
).SetHeader("Accept", "application/vnd.oci.image.manifest.v1+json")
|
|
||||||
try:
|
try:
|
||||||
manifest_response = client.Do(manifest_request)
|
manifests = client.fetch_manifests()
|
||||||
manifest_response.raise_for_status()
|
return client.fetch_blobs(manifests)
|
||||||
except RequestException as exc:
|
except OCIException as exc:
|
||||||
raise BlueprintRetrievalFailed(exc) from exc
|
raise BlueprintRetrievalFailed(exc) from exc
|
||||||
manifest = manifest_response.json()
|
|
||||||
if "errors" in manifest:
|
|
||||||
raise BlueprintRetrievalFailed(manifest["errors"])
|
|
||||||
|
|
||||||
blob = None
|
def retrieve_file(self) -> str:
|
||||||
for layer in manifest.get("layers", []):
|
"""Get blueprint from path"""
|
||||||
if layer.get("mediaType", "") == OCI_MEDIA_TYPE:
|
|
||||||
blob = layer.get("digest")
|
|
||||||
LOGGER.debug("Found layer with matching media type", instance=self, blob=blob)
|
|
||||||
if not blob:
|
|
||||||
raise BlueprintRetrievalFailed("Blob not found")
|
|
||||||
|
|
||||||
blob_request = client.NewRequest(
|
|
||||||
"GET",
|
|
||||||
"/v2/<name>/blobs/<digest>",
|
|
||||||
WithDigest(blob),
|
|
||||||
)
|
|
||||||
try:
|
try:
|
||||||
blob_response = client.Do(blob_request)
|
full_path = Path(CONFIG.y("blueprints_dir")).joinpath(Path(self.path))
|
||||||
blob_response.raise_for_status()
|
with full_path.open("r", encoding="utf-8") as _file:
|
||||||
return blob_response.text
|
return _file.read()
|
||||||
except RequestException as exc:
|
except (IOError, OSError) as exc:
|
||||||
raise BlueprintRetrievalFailed(exc) from exc
|
raise BlueprintRetrievalFailed(exc) from exc
|
||||||
|
|
||||||
def retrieve(self) -> str:
|
def retrieve(self) -> str:
|
||||||
"""Retrieve blueprint contents"""
|
"""Retrieve blueprint contents"""
|
||||||
if self.path.startswith("oci://"):
|
if self.path.startswith("oci://"):
|
||||||
return self.retrieve_oci()
|
return self.retrieve_oci()
|
||||||
full_path = Path(CONFIG.y("blueprints_dir")).joinpath(Path(self.path))
|
if self.path != "":
|
||||||
with full_path.open("r", encoding="utf-8") as _file:
|
return self.retrieve_file()
|
||||||
return _file.read()
|
return self.content
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def serializer(self) -> Serializer:
|
def serializer(self) -> Serializer:
|
||||||
|
|
|
@ -2,7 +2,8 @@
|
||||||
from django.test import TransactionTestCase
|
from django.test import TransactionTestCase
|
||||||
from requests_mock import Mocker
|
from requests_mock import Mocker
|
||||||
|
|
||||||
from authentik.blueprints.models import OCI_MEDIA_TYPE, BlueprintInstance, BlueprintRetrievalFailed
|
from authentik.blueprints.models import BlueprintInstance, BlueprintRetrievalFailed
|
||||||
|
from authentik.blueprints.v1.oci import OCI_MEDIA_TYPE
|
||||||
|
|
||||||
|
|
||||||
class TestBlueprintOCI(TransactionTestCase):
|
class TestBlueprintOCI(TransactionTestCase):
|
||||||
|
|
|
@ -43,3 +43,28 @@ class TestBlueprintsV1API(APITestCase):
|
||||||
"6871c0003f5c07be5c3316d9d4a08444bd8fed1b3f03294e51e44522"
|
"6871c0003f5c07be5c3316d9d4a08444bd8fed1b3f03294e51e44522"
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_api_blank(self):
|
||||||
|
"""Test blank"""
|
||||||
|
res = self.client.post(
|
||||||
|
reverse("authentik_api:blueprintinstance-list"),
|
||||||
|
data={
|
||||||
|
"name": "foo",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual(res.status_code, 400)
|
||||||
|
self.assertJSONEqual(
|
||||||
|
res.content.decode(), {"non_field_errors": ["Either path or content must be set."]}
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_api_content(self):
|
||||||
|
"""Test blank"""
|
||||||
|
res = self.client.post(
|
||||||
|
reverse("authentik_api:blueprintinstance-list"),
|
||||||
|
data={
|
||||||
|
"name": "foo",
|
||||||
|
"content": '{"version": 3}',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual(res.status_code, 400)
|
||||||
|
self.assertJSONEqual(res.content.decode(), {"content": ["Failed to validate blueprint"]})
|
||||||
|
|
|
@ -0,0 +1,98 @@
|
||||||
|
"""OCI Client"""
|
||||||
|
from typing import Any
|
||||||
|
from urllib.parse import ParseResult, urlparse
|
||||||
|
|
||||||
|
from opencontainers.distribution.reggie import (
|
||||||
|
NewClient,
|
||||||
|
WithDebug,
|
||||||
|
WithDefaultName,
|
||||||
|
WithDigest,
|
||||||
|
WithReference,
|
||||||
|
WithUserAgent,
|
||||||
|
WithUsernamePassword,
|
||||||
|
)
|
||||||
|
from requests.exceptions import RequestException
|
||||||
|
from structlog import get_logger
|
||||||
|
from structlog.stdlib import BoundLogger
|
||||||
|
|
||||||
|
from authentik.lib.sentry import SentryIgnoredException
|
||||||
|
from authentik.lib.utils.http import authentik_user_agent
|
||||||
|
|
||||||
|
OCI_MEDIA_TYPE = "application/vnd.goauthentik.blueprint.v1+yaml"
|
||||||
|
|
||||||
|
|
||||||
|
class OCIException(SentryIgnoredException):
|
||||||
|
"""OCI-related errors"""
|
||||||
|
|
||||||
|
|
||||||
|
class BlueprintOCIClient:
|
||||||
|
"""Blueprint OCI Client"""
|
||||||
|
|
||||||
|
url: ParseResult
|
||||||
|
sanitized_url: str
|
||||||
|
logger: BoundLogger
|
||||||
|
ref: str
|
||||||
|
client: NewClient
|
||||||
|
|
||||||
|
def __init__(self, url: str) -> None:
|
||||||
|
self._parse_url(url)
|
||||||
|
self.logger = get_logger().bind(url=self.sanitized_url)
|
||||||
|
|
||||||
|
self.ref = "latest"
|
||||||
|
path = self.url.path[1:]
|
||||||
|
if ":" in self.url.path:
|
||||||
|
path, _, self.ref = path.partition(":")
|
||||||
|
self.client = NewClient(
|
||||||
|
f"https://{self.url.hostname}",
|
||||||
|
WithUserAgent(authentik_user_agent()),
|
||||||
|
WithUsernamePassword(self.url.username, self.url.password),
|
||||||
|
WithDefaultName(path),
|
||||||
|
WithDebug(True),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _parse_url(self, url: str):
|
||||||
|
self.url = urlparse(url)
|
||||||
|
netloc = self.url.netloc
|
||||||
|
if "@" in netloc:
|
||||||
|
netloc = netloc[netloc.index("@") + 1 :]
|
||||||
|
self.sanitized_url = self.url._replace(netloc=netloc).geturl()
|
||||||
|
|
||||||
|
def fetch_manifests(self) -> dict[str, Any]:
|
||||||
|
"""Fetch manifests for ref"""
|
||||||
|
self.logger.info("Fetching OCI manifests for blueprint")
|
||||||
|
manifest_request = self.client.NewRequest(
|
||||||
|
"GET",
|
||||||
|
"/v2/<name>/manifests/<reference>",
|
||||||
|
WithReference(self.ref),
|
||||||
|
).SetHeader("Accept", "application/vnd.oci.image.manifest.v1+json")
|
||||||
|
try:
|
||||||
|
manifest_response = self.client.Do(manifest_request)
|
||||||
|
manifest_response.raise_for_status()
|
||||||
|
except RequestException as exc:
|
||||||
|
raise OCIException(exc) from exc
|
||||||
|
manifest = manifest_response.json()
|
||||||
|
if "errors" in manifest:
|
||||||
|
raise OCIException(manifest["errors"])
|
||||||
|
return manifest
|
||||||
|
|
||||||
|
def fetch_blobs(self, manifest: dict[str, Any]):
|
||||||
|
"""Fetch blob based on manifest info"""
|
||||||
|
blob = None
|
||||||
|
for layer in manifest.get("layers", []):
|
||||||
|
if layer.get("mediaType", "") == OCI_MEDIA_TYPE:
|
||||||
|
blob = layer.get("digest")
|
||||||
|
self.logger.debug("Found layer with matching media type", blob=blob)
|
||||||
|
if not blob:
|
||||||
|
raise OCIException("Blob not found")
|
||||||
|
|
||||||
|
blob_request = self.client.NewRequest(
|
||||||
|
"GET",
|
||||||
|
"/v2/<name>/blobs/<digest>",
|
||||||
|
WithDigest(blob),
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
blob_response = self.client.Do(blob_request)
|
||||||
|
blob_response.raise_for_status()
|
||||||
|
return blob_response.text
|
||||||
|
except RequestException as exc:
|
||||||
|
raise OCIException(exc) from exc
|
13
schema.yml
13
schema.yml
|
@ -25918,6 +25918,7 @@ components:
|
||||||
type: string
|
type: string
|
||||||
path:
|
path:
|
||||||
type: string
|
type: string
|
||||||
|
default: ''
|
||||||
context:
|
context:
|
||||||
type: object
|
type: object
|
||||||
additionalProperties: {}
|
additionalProperties: {}
|
||||||
|
@ -25943,13 +25944,14 @@ components:
|
||||||
type: object
|
type: object
|
||||||
additionalProperties: {}
|
additionalProperties: {}
|
||||||
readOnly: true
|
readOnly: true
|
||||||
|
content:
|
||||||
|
type: string
|
||||||
required:
|
required:
|
||||||
- last_applied
|
- last_applied
|
||||||
- last_applied_hash
|
- last_applied_hash
|
||||||
- managed_models
|
- managed_models
|
||||||
- metadata
|
- metadata
|
||||||
- name
|
- name
|
||||||
- path
|
|
||||||
- pk
|
- pk
|
||||||
- status
|
- status
|
||||||
BlueprintInstanceRequest:
|
BlueprintInstanceRequest:
|
||||||
|
@ -25961,15 +25963,16 @@ components:
|
||||||
minLength: 1
|
minLength: 1
|
||||||
path:
|
path:
|
||||||
type: string
|
type: string
|
||||||
minLength: 1
|
default: ''
|
||||||
context:
|
context:
|
||||||
type: object
|
type: object
|
||||||
additionalProperties: {}
|
additionalProperties: {}
|
||||||
enabled:
|
enabled:
|
||||||
type: boolean
|
type: boolean
|
||||||
|
content:
|
||||||
|
type: string
|
||||||
required:
|
required:
|
||||||
- name
|
- name
|
||||||
- path
|
|
||||||
BlueprintInstanceStatusEnum:
|
BlueprintInstanceStatusEnum:
|
||||||
enum:
|
enum:
|
||||||
- successful
|
- successful
|
||||||
|
@ -33147,12 +33150,14 @@ components:
|
||||||
minLength: 1
|
minLength: 1
|
||||||
path:
|
path:
|
||||||
type: string
|
type: string
|
||||||
minLength: 1
|
default: ''
|
||||||
context:
|
context:
|
||||||
type: object
|
type: object
|
||||||
additionalProperties: {}
|
additionalProperties: {}
|
||||||
enabled:
|
enabled:
|
||||||
type: boolean
|
type: boolean
|
||||||
|
content:
|
||||||
|
type: string
|
||||||
PatchedCaptchaStageRequest:
|
PatchedCaptchaStageRequest:
|
||||||
type: object
|
type: object
|
||||||
description: CaptchaStage Serializer
|
description: CaptchaStage Serializer
|
||||||
|
|
|
@ -20,26 +20,27 @@ import PFToggleGroup from "@patternfly/patternfly/components/ToggleGroup/toggle-
|
||||||
import { BlueprintFile, BlueprintInstance, ManagedApi } from "@goauthentik/api";
|
import { BlueprintFile, BlueprintInstance, ManagedApi } from "@goauthentik/api";
|
||||||
|
|
||||||
enum blueprintSource {
|
enum blueprintSource {
|
||||||
local,
|
file,
|
||||||
oci,
|
oci,
|
||||||
|
internal,
|
||||||
}
|
}
|
||||||
|
|
||||||
@customElement("ak-blueprint-form")
|
@customElement("ak-blueprint-form")
|
||||||
export class BlueprintForm extends ModelForm<BlueprintInstance, string> {
|
export class BlueprintForm extends ModelForm<BlueprintInstance, string> {
|
||||||
@state()
|
@state()
|
||||||
source: blueprintSource = blueprintSource.local;
|
source: blueprintSource = blueprintSource.file;
|
||||||
|
|
||||||
loadInstance(pk: string): Promise<BlueprintInstance> {
|
async loadInstance(pk: string): Promise<BlueprintInstance> {
|
||||||
return new ManagedApi(DEFAULT_CONFIG)
|
const inst = await new ManagedApi(DEFAULT_CONFIG).managedBlueprintsRetrieve({
|
||||||
.managedBlueprintsRetrieve({
|
instanceUuid: pk,
|
||||||
instanceUuid: pk,
|
});
|
||||||
})
|
if (inst.path?.startsWith("oci://")) {
|
||||||
.then((inst) => {
|
this.source = blueprintSource.oci;
|
||||||
if (inst.path.startsWith("oci://")) {
|
}
|
||||||
this.source = blueprintSource.oci;
|
if (inst.content !== "") {
|
||||||
}
|
this.source = blueprintSource.internal;
|
||||||
return inst;
|
}
|
||||||
});
|
return inst;
|
||||||
}
|
}
|
||||||
|
|
||||||
getSuccessMessage(): string {
|
getSuccessMessage(): string {
|
||||||
|
@ -102,12 +103,12 @@ export class BlueprintForm extends ModelForm<BlueprintInstance, string> {
|
||||||
<div class="pf-c-toggle-group__item">
|
<div class="pf-c-toggle-group__item">
|
||||||
<button
|
<button
|
||||||
class="pf-c-toggle-group__button ${this.source ===
|
class="pf-c-toggle-group__button ${this.source ===
|
||||||
blueprintSource.local
|
blueprintSource.file
|
||||||
? "pf-m-selected"
|
? "pf-m-selected"
|
||||||
: ""}"
|
: ""}"
|
||||||
type="button"
|
type="button"
|
||||||
@click=${() => {
|
@click=${() => {
|
||||||
this.source = blueprintSource.local;
|
this.source = blueprintSource.file;
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span class="pf-c-toggle-group__text">${t`Local path`}</span>
|
<span class="pf-c-toggle-group__text">${t`Local path`}</span>
|
||||||
|
@ -128,10 +129,25 @@ export class BlueprintForm extends ModelForm<BlueprintInstance, string> {
|
||||||
<span class="pf-c-toggle-group__text">${t`OCI Registry`}</span>
|
<span class="pf-c-toggle-group__text">${t`OCI Registry`}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="pf-c-divider pf-m-vertical" role="separator"></div>
|
||||||
|
<div class="pf-c-toggle-group__item">
|
||||||
|
<button
|
||||||
|
class="pf-c-toggle-group__button ${this.source ===
|
||||||
|
blueprintSource.internal
|
||||||
|
? "pf-m-selected"
|
||||||
|
: ""}"
|
||||||
|
type="button"
|
||||||
|
@click=${() => {
|
||||||
|
this.source = blueprintSource.internal;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span class="pf-c-toggle-group__text">${t`Internal`}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="pf-c-card__footer">
|
<div class="pf-c-card__footer">
|
||||||
${this.source === blueprintSource.local
|
${this.source === blueprintSource.file
|
||||||
? html`<ak-form-element-horizontal label=${t`Path`} name="path">
|
? html`<ak-form-element-horizontal label=${t`Path`} name="path">
|
||||||
<ak-search-select
|
<ak-search-select
|
||||||
.fetchObjects=${async (
|
.fetchObjects=${async (
|
||||||
|
@ -187,6 +203,15 @@ export class BlueprintForm extends ModelForm<BlueprintInstance, string> {
|
||||||
</p>
|
</p>
|
||||||
</ak-form-element-horizontal>`
|
</ak-form-element-horizontal>`
|
||||||
: html``}
|
: html``}
|
||||||
|
${this.source === blueprintSource.internal
|
||||||
|
? html`<ak-form-element-horizontal label=${t`Blueprint`} name="content">
|
||||||
|
<ak-codemirror
|
||||||
|
mode="yaml"
|
||||||
|
.parseValue=${false}
|
||||||
|
value="${ifDefined(this.instance?.content)}"
|
||||||
|
></ak-codemirror>
|
||||||
|
</ak-form-element-horizontal>`
|
||||||
|
: html``}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
@ -28,6 +28,9 @@ export class CodeMirrorTextarea<T> extends AKElement {
|
||||||
@property()
|
@property()
|
||||||
name?: string;
|
name?: string;
|
||||||
|
|
||||||
|
@property({ type: Boolean })
|
||||||
|
parseValue = true;
|
||||||
|
|
||||||
editor?: EditorView;
|
editor?: EditorView;
|
||||||
|
|
||||||
_value?: string;
|
_value?: string;
|
||||||
|
@ -67,6 +70,9 @@ export class CodeMirrorTextarea<T> extends AKElement {
|
||||||
}
|
}
|
||||||
|
|
||||||
get value(): T | string {
|
get value(): T | string {
|
||||||
|
if (!this.parseValue) {
|
||||||
|
return this.getInnerValue();
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
switch (this.mode.toLowerCase()) {
|
switch (this.mode.toLowerCase()) {
|
||||||
case "yaml":
|
case "yaml":
|
||||||
|
|
|
@ -22,7 +22,7 @@ Blueprints are yaml files, whose format is described further in [File structure]
|
||||||
|
|
||||||
Starting with authentik 2022.8, blueprints are used to manage authentik default flows and other system objects. These blueprints can be disabled/replaced with custom blueprints in certain circumstances.
|
Starting with authentik 2022.8, blueprints are used to manage authentik default flows and other system objects. These blueprints can be disabled/replaced with custom blueprints in certain circumstances.
|
||||||
|
|
||||||
## Storage - Local
|
## Storage - File
|
||||||
|
|
||||||
The authentik container by default looks for blueprints in `/blueprints`. Underneath this directory, there are a couple default subdirectories:
|
The authentik container by default looks for blueprints in `/blueprints`. Underneath this directory, there are a couple default subdirectories:
|
||||||
|
|
||||||
|
@ -49,3 +49,13 @@ To push a blueprint to an OCI-compatible registry, [ORAS](https://oras.land/) ca
|
||||||
```
|
```
|
||||||
oras push ghcr.io/<username>/blueprint/<blueprint name>:latest <yaml file>:application/vnd.goauthentik.blueprint.v1+yaml
|
oras push ghcr.io/<username>/blueprint/<blueprint name>:latest <yaml file>:application/vnd.goauthentik.blueprint.v1+yaml
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Storage - Internal
|
||||||
|
|
||||||
|
:::info
|
||||||
|
Requires authentik 2023.1
|
||||||
|
:::
|
||||||
|
|
||||||
|
Blueprints can be stored in authentik's database, which allows blueprints to be managed via external configuration management tools like Terraform.
|
||||||
|
|
||||||
|
Modifying the contents of a blueprint will trigger its reconciliation. Blueprints are validated on submission to prevent invalid blueprints from being saved.
|
||||||
|
|
Reference in New Issue