This commit is mainly as a result of creating DN entries and improves some backend functions:

* Enable creation of new entries,
* Change all our ajax frames to go through /frames URI instead of /dn,
* Add our frame command to the encrypted DN,
* Automatically redirect to root URL when selecting a tree item and currently in another path (as a result of a prior POST activity),
* Some validation improvements DNExists/HasStructuralObjectClass
This commit is contained in:
2025-02-23 18:14:41 +11:00
parent f08fdb1bcd
commit 996d7bb1dc
27 changed files with 687 additions and 147 deletions

View File

@@ -145,9 +145,9 @@ class Attribute implements \Countable, \ArrayAccess, \Iterator
// Attribute values
'values' => $this->values,
// Required by Object Classes
'required_by' => $this->schema->required_by_object_classes,
'required_by' => $this->schema?->required_by_object_classes ?: collect(),
// Used in Object Classes
'used_in' => $this->schema->used_in_object_classes,
'used_in' => $this->schema?->used_in_object_classes ?: collect(),
default => throw new \Exception('Unknown key:' . $key),
};

View File

@@ -0,0 +1,49 @@
<?php
namespace App\Classes\LDAP\Attribute;
use Illuminate\Contracts\View\View;
use Illuminate\Support\Collection;
use App\Classes\LDAP\Attribute;
/**
* Represents the RDN for an Entry
*/
final class RDN extends Attribute
{
private string $base;
private Collection $attrs;
public function __get(string $key): mixed
{
return match ($key) {
'base' => $this->base,
'attrs' => $this->attrs->pluck('name'),
default => parent::__get($key),
};
}
public function hints(): array
{
return [
'required' => __('RDN is required')
];
}
public function render(bool $edit=FALSE,bool $old=FALSE,bool $new=FALSE): View
{
return view('components.attribute.rdn')
->with('o',$this);
}
public function setAttributes(Collection $attrs): void
{
$this->attrs = $attrs;
}
public function setBase(string $base): void
{
$this->base = $base;
}
}

View File

@@ -208,7 +208,7 @@ final class ObjectClass extends Base
public function __get(string $key): mixed
{
return match ($key) {
'attributes' => $this->getAllAttrs(),
'attributes' => $this->getAllAttrs(TRUE),
'sup' => $this->sup_classes,
'type_name' => match ($this->type) {
Server::OC_STRUCTURAL => 'Structural',
@@ -223,13 +223,18 @@ final class ObjectClass extends Base
/**
* Return a list of attributes that this objectClass provides
*
* @param bool $parents
* @return Collection
* @throws InvalidUsage
*/
public function getAllAttrs(): Collection
public function getAllAttrs(bool $parents=FALSE): Collection
{
return $this->getMustAttrs()
->merge($this->getMayAttrs());
return $this->getMustAttrs($parents)
->transform(function($item) {
$item->required = true;
return $item;
})
->merge($this->getMayAttrs($parents));
}
/**

View File

@@ -16,6 +16,7 @@ namespace App\Classes\LDAP\Schema;
final class ObjectClassAttribute extends Base {
// This Attribute's root.
private string $source;
public bool $required = FALSE;
/**
* Creates a new ObjectClassAttribute with specified name and source objectClass.
@@ -31,11 +32,9 @@ final class ObjectClassAttribute extends Base {
public function __get(string $key): mixed
{
switch ($key) {
case 'source':
return $this->source;
default: return parent::__get($key);
}
return match ($key) {
'source' => $this->source,
default => parent::__get($key),
};
}
}

View File

@@ -16,21 +16,21 @@ class APIController extends Controller
* Get the LDAP server BASE DNs
*
* @return Collection
* @throws LdapRecord\Query\ObjectNotFoundException
* @throws \LdapRecord\Query\ObjectNotFoundException
*/
public function bases(): Collection
{
$base = Server::baseDNs() ?: collect();
return $base->transform(function($item) {
return [
'title'=>$item->getRdn(),
'item'=>$item->getDNSecure(),
'lazy'=>TRUE,
'icon'=>'fa-fw fas fa-sitemap',
'tooltip'=>$item->getDn(),
];
});
return $base
->transform(fn($item)=>
[
'title'=>$item->getRdn(),
'item'=>$item->getDNSecure(),
'lazy'=>TRUE,
'icon'=>'fa-fw fas fa-sitemap',
'tooltip'=>$item->getDn(),
]);
}
/**
@@ -41,19 +41,31 @@ class APIController extends Controller
{
$levels = $request->query('depth',1);
$dn = Crypt::decryptString($request->query('key'));
// Sometimes our key has a command, so we'll ignore it
if (str_starts_with($dn,'*') && ($x=strpos($dn,'|')))
$dn = substr($dn,$x+1);
Log::debug(sprintf('%s: Query [%s] - Levels [%d]',__METHOD__,$dn,$levels));
return (config('server'))
->children($dn)
->transform(function($item) {
return [
->transform(fn($item)=>
[
'title'=>$item->getRdn(),
'item'=>$item->getDNSecure(),
'icon'=>$item->icon(),
'lazy'=>Arr::get($item->getAttribute('hassubordinates'),0) == 'TRUE',
'tooltip'=>$item->getDn(),
];
});
])
->prepend(
[
'title'=>sprintf('[%s]',__('Create Entry')),
'item'=>Crypt::encryptString(sprintf('*%s|%s','create',$dn)),
'lazy'=>FALSE,
'icon'=>'fas fa-fw fa-square-plus text-warning',
'tooltip'=>__('Create new LDAP item here'),
]);
}
public function schema_view(Request $request)
@@ -63,20 +75,20 @@ class APIController extends Controller
switch($request->type) {
case 'objectclasses':
return view('fragment.schema.objectclasses')
->with('objectclasses',$server->schema('objectclasses')->sortBy(function($item) { return strtolower($item->name); }));
->with('objectclasses',$server->schema('objectclasses')->sortBy(fn($item)=>strtolower($item->name)));
case 'attributetypes':
return view('fragment.schema.attributetypes')
->with('server',$server)
->with('attributetypes',$server->schema('attributetypes')->sortBy(function($item) { return strtolower($item->name); }));
->with('attributetypes',$server->schema('attributetypes')->sortBy(fn($item)=>strtolower($item->name)));
case 'ldapsyntaxes':
return view('fragment.schema.ldapsyntaxes')
->with('ldapsyntaxes',$server->schema('ldapsyntaxes')->sortBy(function($item) { return strtolower($item->description); }));
->with('ldapsyntaxes',$server->schema('ldapsyntaxes')->sortBy(fn($item)=>strtolower($item->description)));
case 'matchingrules':
return view('fragment.schema.matchingrules')
->with('matchingrules',$server->schema('matchingrules')->sortBy(function($item) { return strtolower($item->name); }));
->with('matchingrules',$server->schema('matchingrules')->sortBy(fn($item)=>strtolower($item->name)));
default:
abort(404);

View File

@@ -2,6 +2,7 @@
namespace App\Http\Controllers;
use Illuminate\Contracts\View\View;
use Illuminate\Http\Request;
use Illuminate\Support\Arr;
use Illuminate\Support\Collection;
@@ -12,20 +13,21 @@ use Illuminate\Support\Facades\Redirect;
use LdapRecord\Exceptions\InsufficientAccessException;
use LdapRecord\LdapRecordException;
use LdapRecord\Query\ObjectNotFoundException;
use Nette\NotImplementedException;
use App\Classes\LDAP\Attribute\Factory;
use App\Classes\LDAP\{Attribute,Server};
use App\Classes\LDAP\Import\LDIF as LDIFImport;
use App\Classes\LDAP\Export\LDIF as LDIFExport;
use App\Exceptions\Import\{GeneralException,VersionException};
use App\Exceptions\InvalidUsage;
use App\Http\Requests\{EntryRequest,ImportRequest};
use App\Http\Requests\{EntryRequest,EntryAddRequest,ImportRequest};
use App\Ldap\Entry;
use App\View\Components\AttributeType;
use Nette\NotImplementedException;
class HomeController extends Controller
{
private function bases()
private function bases(): Collection
{
$base = Server::baseDNs() ?: collect();
@@ -51,21 +53,38 @@ class HomeController extends Controller
}
/**
* Render a specific DN
* Create a new object in the LDAP server
*
* @param Request $request
* @param EntryAddRequest $request
* @return \Illuminate\Contracts\Foundation\Application|\Illuminate\Contracts\View\Factory|\Illuminate\Contracts\View\View
* @throws InvalidUsage
*/
public function dn_frame(Request $request)
public function entry_add(EntryAddRequest $request)
{
$dn = Crypt::decryptString($request->post('key'));
if (! old('step',$request->validated('step')))
abort(404);
$page_actions = collect(['edit'=>TRUE,'copy'=>TRUE]);
$key = $this->request_key($request,collect(old()));
return view('frames.dn')
->with('o',config('server')->fetch($dn))
->with('dn',$dn)
->with('page_actions',$page_actions);
$o = new Entry;
if (count(array_filter($x=old('objectclass',$request->objectclass)))) {
$o->objectclass = $x;
foreach($o->getAvailableAttributes()->filter(fn($item)=>$item->required) as $ao)
$o->addAttribute($ao,'');
$o->setRDNBase($key['dn']);
}
$step = $request->step ? $request->step+1 : old('step');
return view('frame')
->with('subframe','create')
->with('bases',$this->bases())
->with('o',$o)
->with('step',$step)
->with('container',old('container',$key['dn']));
}
/**
@@ -75,7 +94,7 @@ class HomeController extends Controller
* @param string $id
* @return \Closure|\Illuminate\Contracts\View\View|string
*/
public function entry_attr_add(Request $request,string $id)
public function entry_attr_add(Request $request,string $id): string
{
$xx = new \stdClass();
$xx->index = 0;
@@ -90,6 +109,53 @@ class HomeController extends Controller
return $x;
}
public function entry_create(EntryAddRequest $request)
{
$key = $this->request_key($request,collect(old()));
$dn = sprintf('%s=%s,%s',$request->rdn,$request->rdn_value,$key['dn']);
$o = new Entry;
$o->setDn($dn);
foreach ($request->except(['_token','key','step','rdn','rdn_value']) as $key => $value)
$o->{$key} = array_filter($value);
try {
$o->save();
} catch (InsufficientAccessException $e) {
$request->flash();
switch ($x=$e->getDetailedError()->getErrorCode()) {
case 50:
return Redirect::to('/')
->withInput()
->withErrors(sprintf('%s: %s (%s)',__('LDAP Server Error Code'),$x,__($e->getDetailedError()->getErrorMessage())));
default:
abort(599,$e->getDetailedError()->getErrorMessage());
}
// @todo To test and valide this Exception is caught
} catch (LdapRecordException $e) {
$request->flash();
switch ($x=$e->getDetailedError()->getErrorCode()) {
case 8:
return Redirect::to('/')
->withInput()
->withErrors(sprintf('%s: %s (%s)',__('LDAP Server Error Code'),$x,__($e->getDetailedError()->getErrorMessage())));
default:
abort(599,$e->getDetailedError()->getErrorMessage());
}
}
return Redirect::to('/')
->withFragment($o->getDNSecure());
}
public function entry_export(Request $request,string $id)
{
$dn = Crypt::decryptString($id);
@@ -112,11 +178,9 @@ class HomeController extends Controller
* @param string $id
* @return mixed
*/
public function entry_objectclass_add(string $id)
public function entry_objectclass_add(Request $request)
{
$dn = Crypt::decryptString($id);
$o = config('server')->fetch($dn);
$oc = $o->getObject('objectclass');
$oc = Factory::create('objectclass',$request->oc);
$ocs = $oc
->structural
@@ -259,26 +323,51 @@ class HomeController extends Controller
}
/**
* Application home page
* Render a frame, normally as a result of an AJAX call
* This will render the right frame.
*
* @param Request $request
* @param Collection|null $old
* @return \Illuminate\Contracts\Foundation\Application|\Illuminate\Contracts\View\Factory|View
*/
public function home()
public function frame(Request $request,?Collection $old=NULL): View
{
if (old('dn'))
return view('frame')
->with('subframe','dn')
->with('bases',$this->bases())
->with('o',config('server')->fetch($dn=Crypt::decryptString(old('dn'))))
->with('dn',$dn);
// If our index was not render from a root url, then redirect to it
if (($request->root().'/' !== url()->previous()) && $request->method() === 'POST')
abort(409);
elseif (old('frame'))
return view('frame')
->with('subframe',old('frame'))
$key = $this->request_key($request,$old);
$view = ($old
? view('frame')->with('subframe',$key['cmd'])
: view('frames.'.$key['cmd']))
->with('bases',$this->bases());
return match ($key['cmd']) {
'create' => $view
->with('container',old('container',$key['dn']))
->with('step',1),
'dn' => $view
->with('dn',$key['dn'])
->with('page_actions',collect(['edit'=>TRUE,'copy'=>TRUE])),
'import' => $view,
default => abort(404),
};
}
/**
* This is the main page render function
*/
public function home(Request $request)
{
// Did we come here as a result of a redirect
return count(old())
? $this->frame($request,collect(old()))
: view('home')
->with('bases',$this->bases());
else
return view('home')
->with('bases',$this->bases())
->with('server',config('ldap.connections.default.name'));
}
/**
@@ -334,6 +423,39 @@ class HomeController extends Controller
->with('s',config('server'));
}
/**
* For any incoming request, work out the command and DN involved
*
* @param Request $request
* @param Collection|null $old
* @return array
*/
private function request_key(Request $request,?Collection $old=NULL): array
{
// Setup
$cmd = NULL;
$dn = NULL;
$key = $request->get('key',old('key'))
? Crypt::decryptString($request->get('key',old('key')))
: NULL;
// Determine if our key has a command
if (str_contains($key,'|')) {
$m = [];
if (preg_match('/\*([a-z_]+)\|(.+)$/',$key,$m)) {
$cmd = $m[1];
$dn = ($m[2] !== '_NOP') ? $m[2] : NULL;
}
} elseif (old('dn',$request->get('key'))) {
$cmd = 'dn';
$dn = Crypt::decryptString(old('dn',$request->get('key')));
}
return ['cmd'=>$cmd,'dn'=>$dn];
}
/**
* Show the Schema Viewer
*

View File

@@ -0,0 +1,71 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Facades\Crypt;
use App\Rules\{DNExists,HasStructuralObjectClass};
class EntryAddRequest extends FormRequest
{
/**
* Get the error messages for the defined validation rules.
*
* @return array<string, string>
*/
public function messages(): array
{
return [
'rdn' => __('RDN is required.'),
'rdn_value' => __('RDN value is required.'),
];
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, mixed>
* @throws \Psr\Container\ContainerExceptionInterface
* @throws \Psr\Container\NotFoundExceptionInterface
*/
public function rules(): array
{
if (request()->method() === 'GET')
return [];
return config('server')
->schema('attributetypes')
->intersectByKeys($this->request)
->map(fn($item)=>$item->validation(request()->get('objectclass')))
->filter()
->flatMap(fn($item)=>$item)
->merge([
'key' => [
'required',
new DNExists,
function (string $attribute,mixed $value,\Closure $fail) {
$cmd = Crypt::decryptString($value);
// Sometimes our key has a command, so we'll ignore it
if (str_starts_with($cmd,'*') && ($x=strpos($cmd,'|')))
$cmd = substr($cmd,1,$x-1);
if ($cmd !== 'create') {
$fail(sprintf('Invalid command: %s',$cmd));
}
},
],
'rdn' => 'required_if:step,2|string|min:1',
'rdn_value' => 'required_if:step,2|string|min:1',
'step' => 'int|min:1|max:2',
'objectclass'=>[
'required',
'array',
'min:1',
new HasStructuralObjectClass,
]
])
->toArray();
}
}

View File

@@ -6,27 +6,17 @@ use Illuminate\Foundation\Http\FormRequest;
class EntryRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*
* @return bool
*/
public function authorize()
{
return TRUE;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, mixed>
*/
public function rules()
public function rules(): array
{
return config('server')
->schema('attributetypes')
->intersectByKeys($this->request)
->map(fn($item)=>$item->validation(request()->get('objectclass')))
->map(fn($item)=>$item->validation(request()?->get('objectclass') ?: []))
->filter()
->flatMap(fn($item)=>$item)
->toArray();

View File

@@ -6,15 +6,9 @@ use Illuminate\Foundation\Http\FormRequest;
class ImportRequest extends FormRequest
{
public function authorize()
{
return TRUE;
}
public function rules()
public function rules(): array
{
return [
'frame' => 'required|string|in:import',
'file' => 'nullable|extensions:ldif|required_without:text',
'text'=> 'nullable|prohibits:file|string|min:16',
];

View File

@@ -12,11 +12,14 @@ use App\Classes\LDAP\Attribute;
use App\Classes\LDAP\Attribute\Factory;
use App\Classes\LDAP\Export\LDIF;
use App\Exceptions\Import\AttributeException;
use App\Exceptions\InvalidUsage;
class Entry extends Model
{
private Collection $objects;
private bool $noObjectAttributes = FALSE;
// For new entries, this is the container that this entry will be stored in
private string $rdnbase;
/* OVERRIDES */
@@ -46,7 +49,7 @@ class Entry extends Model
public function getAttributes(): array
{
return $this->objects
->map(fn($item)=>$item->values->toArray())
->map(fn($item)=>$item->values)
->toArray();
}
@@ -92,10 +95,7 @@ class Entry extends Model
$key = $this->normalizeAttributeKey($key);
if ((! $this->objects->get($key)) && $value) {
$o = new Attribute($key,[]);
$o->value = $value;
$this->objects->put($key,$o);
$this->objects->put($key,Factory::create($key,$value));
} elseif ($this->objects->get($key)) {
$this->objects->get($key)->value = $this->attributes[$key];
@@ -265,8 +265,12 @@ class Entry extends Model
*/
public function getObject(string $key): Attribute|null
{
return $this->objects
->get($this->normalizeAttributeKey($key));
return match ($key) {
'rdn' => $this->getRDNObject(),
default => $this->objects
->get($this->normalizeAttributeKey($key))
};
}
public function getObjects(): Collection
@@ -289,6 +293,16 @@ class Entry extends Model
->filter(fn($a)=>(! $this->getVisibleAttributes()->contains(fn($b)=>($a->name === $b->name))));
}
private function getRDNObject(): Attribute\RDN
{
$o = new Attribute\RDN('dn',['']);
// @todo for an existing object, return the base.
$o->setBase($this->rdnbase);
$o->setAttributes($this->getAvailableAttributes()->filter(fn($item)=>$item->required));
return $o;
}
/**
* Return this list of user attributes
*
@@ -413,4 +427,12 @@ class Entry extends Model
return $this;
}
public function setRDNBase(string $bdn): void
{
if ($this->exists)
throw new InvalidUsage('Cannot set RDN base on existing entries');
$this->rdnbase = $bdn;
}
}

27
app/Rules/DNExists.php Normal file
View File

@@ -0,0 +1,27 @@
<?php
namespace App\Rules;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Support\Facades\Crypt;
class DNExists implements ValidationRule
{
/**
* Run the validation rule.
*
* @param \Closure(string, ?string=): \Illuminate\Translation\PotentiallyTranslatedString $fail
*/
public function validate(string $attribute,mixed $value,Closure $fail): void
{
$dn = Crypt::decryptString($value);
// Sometimes our key has a command, so we'll ignore it
if (str_starts_with($dn,'*') && ($x=strpos($dn,'|')))
$dn = substr($dn,$x+1);
if (! config('server')->fetch($dn))
$fail(sprintf('The DN %s doesnt exist.',$dn));
}
}

View File

@@ -0,0 +1,23 @@
<?php
namespace App\Rules;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
class HasStructuralObjectClass implements ValidationRule
{
/**
* Run the validation rule.
*
* @param \Closure(string, ?string=): \Illuminate\Translation\PotentiallyTranslatedString $fail
*/
public function validate(string $attribute,mixed $value,Closure $fail): void
{
foreach ($value as $item)
if ($item && config('server')->schema('objectclasses',$item)->isStructural())
return;
$fail('There isnt a Structural Objectclass.');
}
}