- Updated to match Notes-2.x
This commit is contained in:
parent
4b9a162696
commit
5cd0d13404
|
@ -9,6 +9,7 @@ use Ulmus\{Adapter\AdapterInterface,
|
|||
Migration\FieldDefinition,
|
||||
Ulmus};
|
||||
|
||||
use LDAP\Result;
|
||||
use Ulmus\Ldap\Common\LdapObject;
|
||||
|
||||
use function ldap_set_option, ldap_start_tls, ldap_bind, ldap_unbind, ldap_connect, ldap_close, ldap_get_entries;
|
||||
|
@ -28,15 +29,15 @@ class Ldap implements \Ulmus\Adapter\AdapterInterface {
|
|||
public bool $encrypt;
|
||||
|
||||
public bool $forceSSL = false;
|
||||
|
||||
|
||||
public array $hosts;
|
||||
|
||||
|
||||
public string $baseDn;
|
||||
|
||||
public string $username;
|
||||
|
||||
|
||||
public string $password;
|
||||
|
||||
|
||||
public string $accountSuffix;
|
||||
|
||||
public string $pathCertCrt;
|
||||
|
@ -73,11 +74,11 @@ class Ldap implements \Ulmus\Adapter\AdapterInterface {
|
|||
}
|
||||
}
|
||||
|
||||
public function authenticate(string $dn, string $password) : bool
|
||||
public function authenticate(string $dn, string $password) : false|Result
|
||||
{
|
||||
$this->ldapObject = $this->getLdapObject();
|
||||
|
||||
return $this->ldapObject->bind($dn, $password);;
|
||||
return $this->ldapObject->bind($dn, $password);
|
||||
}
|
||||
|
||||
public function connect() : object
|
||||
|
@ -92,7 +93,7 @@ class Ldap implements \Ulmus\Adapter\AdapterInterface {
|
|||
public function bindUser() : void
|
||||
{
|
||||
if ( ! $this->ldapObject->bind($this->username, $this->password) ) {
|
||||
throw new \Exception("LDAP bind failed with given user $usr.");
|
||||
throw new \Exception("LDAP bind failed with given user {$this->username}.");
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -118,7 +119,7 @@ class Ldap implements \Ulmus\Adapter\AdapterInterface {
|
|||
elseif ($this->forceSSL) {
|
||||
$ldapObject->startTLS();
|
||||
}
|
||||
|
||||
|
||||
return $ldapObject;
|
||||
}
|
||||
|
||||
|
@ -126,11 +127,11 @@ class Ldap implements \Ulmus\Adapter\AdapterInterface {
|
|||
{
|
||||
return "";
|
||||
}
|
||||
|
||||
|
||||
public function setup(array $configuration) : void
|
||||
{
|
||||
$configuration = array_change_key_case($configuration, \CASE_LOWER);
|
||||
|
||||
|
||||
if ( false === ( $this->hosts = $configuration['hosts'] ?? false ) ) {
|
||||
throw new AdapterConfigurationException("Your `host` setting is missing. It is a mandatory parameter for this driver.");
|
||||
}
|
||||
|
@ -166,7 +167,7 @@ class Ldap implements \Ulmus\Adapter\AdapterInterface {
|
|||
|
||||
return $value;
|
||||
}
|
||||
|
||||
|
||||
public function escapeIdentifier(string $segment, int $type, string $ignore = "") : string
|
||||
{
|
||||
switch($type) {
|
||||
|
|
|
@ -3,7 +3,8 @@
|
|||
namespace Ulmus\Ldap\Common;
|
||||
|
||||
use http\Exception\InvalidArgumentException;
|
||||
use function ldap_set_option, ldap_start_tls, ldap_bind, ldap_unbind, ldap_connect, ldap_close, ldap_get_entries, ldap_mod_replace, ldap_count_entries, ldap_errno, ldap_error;
|
||||
use LDAP\Result;
|
||||
use function ldap_set_option, ldap_start_tls, ldap_bind_ext, ldap_unbind, ldap_connect, ldap_close, ldap_get_entries, ldap_mod_replace, ldap_count_entries, ldap_errno, ldap_error;
|
||||
|
||||
class LdapObject {
|
||||
|
||||
|
@ -23,7 +24,9 @@ class LdapObject {
|
|||
|
||||
public string $dn;
|
||||
|
||||
public bool $binded = false;
|
||||
public false|Result $binded = false;
|
||||
|
||||
public false|Result $lastQuery = false;
|
||||
|
||||
public bool $deleteRecursively = true;
|
||||
|
||||
|
@ -55,14 +58,14 @@ class LdapObject {
|
|||
ldap_start_tls($this->connection);
|
||||
}
|
||||
|
||||
public function bind(? string $dn, ? string $password = null) : bool
|
||||
public function bind(? string $dn, ? string $password = null) : false|Result
|
||||
{
|
||||
if ($this->binded) {
|
||||
throw new \Exception("LdapObject is already binded with a user. Use the unbind() method to release it.");
|
||||
}
|
||||
|
||||
try {
|
||||
$this->binded = ldap_bind($this->connection, $dn, $password);
|
||||
$this->binded = ldap_bind_ext($this->connection, $dn, $password);
|
||||
}
|
||||
catch(\Throwable $ex) {
|
||||
throw new \ErrorException(sprintf("%s [ using: %s on %s ]", $ex->getMessage(), $dn, $this->host ));
|
||||
|
@ -172,31 +175,50 @@ class LdapObject {
|
|||
}
|
||||
|
||||
try {
|
||||
if (false === ($queryResult = ldap_add($this->connection, $dn, $combine))) {
|
||||
|
||||
$this->lastQuery = ldap_add_ext($this->connection, $dn, $combine);
|
||||
|
||||
if ($this->lastQuery === false) {
|
||||
$this->throwLdapException();
|
||||
}
|
||||
else {
|
||||
$this->rowCount = 1;
|
||||
$this->lastInsertId = $dn;
|
||||
}
|
||||
}
|
||||
catch(\Throwable $e) {
|
||||
$this->throwLdapException();
|
||||
}
|
||||
|
||||
if ($queryResult) {
|
||||
$this->rowCount = (int)$queryResult;
|
||||
$this->lastInsertId = $dn;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function runAddQuery(array $filter, array $dataset)
|
||||
{
|
||||
static::$dump && call_user_func_array(static::$dump, [ $filter, $dataset ]);
|
||||
|
||||
$this->lastQuery = ldap_mod_add_ext($this->connection, $filter['dn'], $dataset);
|
||||
|
||||
if ( $this->lastQuery === false ) {
|
||||
$this->throwLdapException();
|
||||
}
|
||||
|
||||
$this->rowCount = $this->lastQuery === false ? 0 : 1;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function runUpdateQuery(array $filter, array $dataset)
|
||||
{
|
||||
static::$dump && call_user_func_array(static::$dump, [ $filter, $dataset ]);
|
||||
|
||||
if ( false === ( $queryResult = ldap_mod_replace($this->connection, $filter['dn'], $dataset) ) ) {
|
||||
|
||||
$this->lastQuery = ldap_mod_replace_ext($this->connection, $filter['dn'], $dataset);
|
||||
|
||||
if ( $this->lastQuery === false ) {
|
||||
$this->throwLdapException();
|
||||
}
|
||||
|
||||
$this->rowCount = (int) $queryResult;
|
||||
$this->rowCount = $this->lastQuery === false ? 0 : 1;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
@ -218,11 +240,13 @@ class LdapObject {
|
|||
}
|
||||
}
|
||||
|
||||
if ( false === ( $queryResult = ldap_delete($this->connection, $filter['dn']) ) ) {
|
||||
$this->lastQuery = ldap_delete_ext($this->connection, $filter['dn']);
|
||||
|
||||
if ( $this->lastQuery === false ) {
|
||||
$this->throwLdapException();
|
||||
}
|
||||
|
||||
$this->rowCount = (int) $queryResult;
|
||||
$this->rowCount = $this->lastQuery === false ? 0 : 1;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
@ -237,7 +261,9 @@ class LdapObject {
|
|||
return;
|
||||
}
|
||||
|
||||
throw new \Exception(sprintf('LDAP error #%s `%s`', $no, ldap_error($this->connection)));
|
||||
ldap_get_option($this->connection, \LDAP_OPT_DIAGNOSTIC_MESSAGE, $err);
|
||||
|
||||
throw new \Exception(sprintf('LDAP error #%s `%s`. %s', $no, ldap_error($this->connection), $err));
|
||||
}
|
||||
|
||||
public function closeCursor() : void {}
|
||||
|
|
|
@ -4,9 +4,12 @@ namespace Ulmus\Ldap\Entity;
|
|||
|
||||
use Ulmus\Ldap\Entity\Field\{ Datetime };
|
||||
|
||||
use Ulmus\Attribute\Property\Field;
|
||||
use Ulmus\Attribute\Property\{Field, Filter, Relation, Virtual};
|
||||
|
||||
use Ulmus\EntityCollection;
|
||||
use Ulmus\Ldap\Attribute\Obj\ObjectClass;
|
||||
use Ulmus\Ldap\Repository;
|
||||
use Ulmus\Repository\RepositoryInterface;
|
||||
|
||||
#[ObjectClass("group")]
|
||||
class Group
|
||||
|
@ -17,10 +20,10 @@ class Group
|
|||
public string $samaccountname;
|
||||
|
||||
#[Field]
|
||||
public array $members;
|
||||
public string $name;
|
||||
|
||||
#[Field]
|
||||
public string $name;
|
||||
public string $description;
|
||||
|
||||
#[Field]
|
||||
public string $canonicalName;
|
||||
|
@ -28,6 +31,10 @@ class Group
|
|||
#[Field(name: "objectGUID")]
|
||||
public string $guid;
|
||||
|
||||
# You will need to override this method in your entity because an adapter needs to be set for the User entity.
|
||||
#[Relation(type: Relation\RelationTypeEnum::oneToMany, key: 'dn', foreignKey: "memberOf", entity: User::class)]
|
||||
public EntityCollection $members;
|
||||
|
||||
public function __toString() : string
|
||||
{
|
||||
return $this->name;
|
||||
|
|
|
@ -0,0 +1,21 @@
|
|||
<?php
|
||||
|
||||
namespace Ulmus\Ldap\Entity;
|
||||
|
||||
use Ulmus\Ldap\Entity\Field\{ Datetime };
|
||||
|
||||
use Ulmus\Attribute\Property\{Field, Filter, Relation, Virtual};
|
||||
|
||||
use Ulmus\EntityCollection;
|
||||
use Ulmus\Ldap\Attribute\Obj\ObjectClass;
|
||||
use Ulmus\Ldap\Repository;
|
||||
use Ulmus\Repository\RepositoryInterface;
|
||||
|
||||
#[ObjectClass("group")]
|
||||
class GroupMember
|
||||
{
|
||||
use \Ulmus\Ldap\EntityTrait;
|
||||
|
||||
#[Field]
|
||||
public string $member;
|
||||
}
|
|
@ -61,8 +61,8 @@ class User
|
|||
#[Field]
|
||||
public string $title;
|
||||
|
||||
##[Field(readonly:true)]
|
||||
# public ? array $memberOf;
|
||||
#[Field(readonly:true)]
|
||||
public ? array $memberOf;
|
||||
|
||||
##[Field(readonly:true)]
|
||||
#public ? array $proxyAddresses;
|
||||
|
@ -149,12 +149,17 @@ class User
|
|||
|
||||
public function memberOfGroup() : array
|
||||
{
|
||||
$arr = array_map(fn($e) => explode('=', explode(',', $e)[0])[1], $this->memberOf);
|
||||
$arr = array_map(fn($e) => explode('=', explode(',', $e)[0])[1], (array) $this->memberOf);
|
||||
usort($arr, 'strcasecmp');
|
||||
|
||||
return $arr;
|
||||
}
|
||||
|
||||
public function isMemberOf(string $name) : bool
|
||||
{
|
||||
return in_array(strtolower($name), array_map('strtolower', $this->memberOfGroup()), true);
|
||||
}
|
||||
|
||||
public function brokenMigration() : bool
|
||||
{
|
||||
if ( empty($this->targetAddress) ) {
|
||||
|
|
|
@ -2,8 +2,16 @@
|
|||
|
||||
namespace Ulmus\Ldap;
|
||||
|
||||
use Ulmus\{Attribute\Property\Field, Ulmus, EventTrait, Query, Common\EntityResolver, Common\EntityField};
|
||||
use Ulmus\{Attribute\Property\Field,
|
||||
ConnectionAdapter,
|
||||
Repository,
|
||||
Ulmus,
|
||||
EventTrait,
|
||||
Query,
|
||||
Common\EntityResolver,
|
||||
Common\EntityField};
|
||||
|
||||
use Notes\Attribute\Ignore;
|
||||
use Ulmus\Ldap\Annotation\Classes\{ ObjectClass, ObjectType, };
|
||||
|
||||
trait EntityTrait {
|
||||
|
@ -18,25 +26,29 @@ trait EntityTrait {
|
|||
#[Field]
|
||||
public array $objectClass;
|
||||
|
||||
#[Ignore]
|
||||
public static function resolveEntity() : EntityResolver
|
||||
{
|
||||
return Ulmus::resolveEntity(static::class);
|
||||
}
|
||||
|
||||
public static function repository(string $alias = Repository::DEFAULT_ALIAS) : Repository
|
||||
#[Ignore]
|
||||
public static function repository(string $alias = Repository::DEFAULT_ALIAS, ConnectionAdapter $adapter = null) : Repository
|
||||
{
|
||||
return new Repository(static::class, $alias);
|
||||
return Ulmus::repository(static::class, $alias, $adapter);
|
||||
}
|
||||
|
||||
public static function field($name, ? string $alias = null) : EntityField
|
||||
#[Ignore]
|
||||
public static function field($name, null|string|false $alias = Repository::DEFAULT_ALIAS) : EntityField
|
||||
{
|
||||
return new EntityField(static::class, $name, $alias ?: Repository::DEFAULT_ALIAS, Ulmus::resolveEntity(static::class));
|
||||
return new EntityField(static::class, $name, false, Ulmus::resolveEntity(static::class));
|
||||
}
|
||||
|
||||
public static function fields(array $fields, ? string $alias = null) : string
|
||||
#[Ignore]
|
||||
public static function fields(array $fields, null|string|false $alias = Repository::DEFAULT_ALIAS, string $separator = ', ') : string
|
||||
{
|
||||
return implode(', ', array_map(function($item) use ($alias){
|
||||
return static::field($item, $alias);
|
||||
return implode(', ', array_map(function($item) {
|
||||
return static::field($item, false);
|
||||
}, $fields));
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,7 @@
|
|||
<?php
|
||||
|
||||
namespace Ulmus\Ldap\Query;
|
||||
|
||||
class Add extends Dn {
|
||||
|
||||
}
|
|
@ -2,16 +2,6 @@
|
|||
|
||||
namespace Ulmus\Ldap\Query;
|
||||
|
||||
class Delete extends \Ulmus\Query\Fragment {
|
||||
class Delete extends Dn {
|
||||
|
||||
public int $order = -100;
|
||||
|
||||
public string $dn;
|
||||
|
||||
public function render() : array
|
||||
{
|
||||
return [
|
||||
'dn' => $this->dn,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,17 @@
|
|||
<?php
|
||||
|
||||
namespace Ulmus\Ldap\Query;
|
||||
|
||||
class Dn extends \Ulmus\Query\Fragment {
|
||||
|
||||
public int $order = -100;
|
||||
|
||||
public string $dn;
|
||||
|
||||
public function render() : array
|
||||
{
|
||||
return [
|
||||
'dn' => $this->dn,
|
||||
];
|
||||
}
|
||||
}
|
|
@ -45,14 +45,20 @@ class Filter extends Fragment {
|
|||
return $this;
|
||||
}
|
||||
|
||||
public function render(bool $skipToken = false) : array
|
||||
public function render(bool $raw = false) : array
|
||||
{
|
||||
if ($raw) {
|
||||
return [
|
||||
'filters' => $this->conditionList
|
||||
];
|
||||
}
|
||||
|
||||
$stack = [];
|
||||
|
||||
|
||||
foreach ($this->conditionList ?? [] as $key => $item) {
|
||||
if ( $item instanceof Filter ) {
|
||||
if ( $item->conditionList ?? false ) {
|
||||
$stack[] = ( $key !== 0 ? "{$item->condition} " : "" ) . "(" . implode('', $item->render($skipToken)) . ")";
|
||||
$stack[] = ( $key !== 0 ? "{$item->condition} " : "" ) . "(" . implode('', $item->render()) . ")";
|
||||
}
|
||||
}
|
||||
else {
|
||||
|
|
|
@ -2,16 +2,6 @@
|
|||
|
||||
namespace Ulmus\Ldap\Query;
|
||||
|
||||
class Update extends \Ulmus\Query\Fragment {
|
||||
class Update extends Dn {
|
||||
|
||||
public int $order = -100;
|
||||
|
||||
public string $dn;
|
||||
|
||||
public function render() : array
|
||||
{
|
||||
return [
|
||||
'dn' => $this->dn,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
@ -79,10 +79,34 @@ class QueryBuilder implements Ulmus\QueryBuilder\QueryBuilderInterface
|
|||
return $this;
|
||||
}
|
||||
|
||||
public function dn(string $dn) : self
|
||||
{
|
||||
if ( null === ( $update = $this->getFragment(Query\Dn::class) ) ) {
|
||||
$update = new Query\Dn();
|
||||
$this->push($update);
|
||||
}
|
||||
|
||||
$update->dn = $dn;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function add(string $dn) : self
|
||||
{
|
||||
if ( null === ( $add = $this->getFragment(Query\Add::class) ) ) {
|
||||
$add = new Query\Add();
|
||||
$this->push($add);
|
||||
}
|
||||
|
||||
$add->dn = $dn;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function update(string $dn) : self
|
||||
{
|
||||
if ( null === ( $update = $this->getFragment(Query\Update::class) ) ) {
|
||||
$update = new Query\Update($this);
|
||||
$update = new Query\Update();
|
||||
$this->push($update);
|
||||
}
|
||||
|
||||
|
@ -94,7 +118,7 @@ class QueryBuilder implements Ulmus\QueryBuilder\QueryBuilderInterface
|
|||
public function delete(string $dn) : self
|
||||
{
|
||||
if ( null === ( $delete = $this->getFragment(Query\Delete::class) ) ) {
|
||||
$delete = new Query\Delete($this);
|
||||
$delete = new Query\Delete();
|
||||
$this->push($delete);
|
||||
}
|
||||
|
||||
|
|
|
@ -2,8 +2,8 @@
|
|||
|
||||
namespace Ulmus\Ldap;
|
||||
|
||||
use Ulmus\Ldap\Annotation\Classes\{ ObjectClass, ObjectType };
|
||||
use Ulmus\{EntityCollection, Ulmus, SearchRequest, Query};
|
||||
use Ulmus\Ldap\Attribute\Obj\{ ObjectClass, ObjectType };
|
||||
use Ulmus\{EntityCollection, Event\Repository\CollectionFromQueryItemInterface, Ulmus, SearchRequest, Ldap\Query};
|
||||
use Ulmus\Annotation\Property\{ Where, Having, Relation, Join, WithJoin, Relation\Ignore as RelationIgnore };
|
||||
use Ulmus\Common\EntityResolver;
|
||||
|
||||
|
@ -21,17 +21,52 @@ class Repository extends \Ulmus\Repository
|
|||
$this->queryBuilder = new QueryBuilder();
|
||||
}
|
||||
|
||||
public function saveAdd(object|array $entity, ? array $fieldsAndValue = null, bool $replace = false) : bool
|
||||
{
|
||||
if (is_array($entity)) {
|
||||
$entity = (new $this->entityClass())->fromArray($entity);
|
||||
}
|
||||
|
||||
$dataset = $entity->toArray();
|
||||
|
||||
$primaryKeyDefinition = Ulmus::resolveEntity($this->entityClass)->getPrimaryKeyField();
|
||||
|
||||
if ( $primaryKeyDefinition === null ) {
|
||||
throw new \Exception(sprintf("No primary key found for entity %s", $this->entityClass));
|
||||
}
|
||||
|
||||
$diff = $fieldsAndValue ?? $this->generateWritableDataset($entity);
|
||||
|
||||
if ( [] !== $diff ) {
|
||||
$pkField = key($primaryKeyDefinition);
|
||||
$pkFieldName = $primaryKeyDefinition[$pkField]->name ?? $pkField;
|
||||
$this->where($pkFieldName, $dataset[$pkFieldName]);
|
||||
|
||||
$this->updateSqlQuery($diff)->finalizeQuery();
|
||||
|
||||
$ldapObject = $this->adapter->connector()->runAddQuery($this->queryBuilder->render(), array_merge($this->queryBuilder->values ?? [], $this->queryBuilder->parameters ?? []));
|
||||
|
||||
$this->queryBuilder->reset();
|
||||
|
||||
$entity->entityFillFromDataset($dataset, true);
|
||||
|
||||
return $ldapObject ? (bool) $ldapObject->rowCount : false;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
protected function selectSqlQuery() : self
|
||||
{
|
||||
if ( null === $this->queryBuilder->getFragment(Query\Select::class) ) {
|
||||
$this->select(array_keys($this->entityResolver->fieldList(EntityResolver::KEY_COLUMN_NAME)));
|
||||
}
|
||||
|
||||
if ( null !== $objectClass = $this->entityResolver->getAnnotationFromClassname( ObjectClass::class, false ) ) {
|
||||
if ( null !== $objectClass = $this->entityResolver->getAttributeImplementing( ObjectClass::class ) ) {
|
||||
$this->where('objectclass', $objectClass->type);
|
||||
}
|
||||
|
||||
if ( null !== $objectClass = $this->entityResolver->getAnnotationFromClassname( ObjectType::class, false ) ) {
|
||||
if ( null !== $objectClass = $this->entityResolver->getAttributeImplementing( ObjectType::class ) ) {
|
||||
$this->where('objecttype', $objectClass->type);
|
||||
}
|
||||
|
||||
|
@ -53,6 +88,7 @@ class Repository extends \Ulmus\Repository
|
|||
|
||||
protected function updateSqlQuery(array $dataset) : self
|
||||
{
|
||||
# Backward compatibility here !
|
||||
$condition = array_pop($this->queryBuilder->where->conditionList);
|
||||
|
||||
if ($condition[0] === 'dn') {
|
||||
|
@ -130,6 +166,13 @@ class Repository extends \Ulmus\Repository
|
|||
return $this;
|
||||
}
|
||||
|
||||
public function add(string $dn) : self
|
||||
{
|
||||
$this->queryBuilder->add($dn);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function escapeValue($identifier) : string
|
||||
{
|
||||
return is_object($identifier) ? $identifier : $this->adapter->adapter()->escapeIdentifier($identifier, Adapter\Ldap::IDENTIFIER_FILTER);
|
||||
|
@ -157,4 +200,20 @@ class Repository extends \Ulmus\Repository
|
|||
|
||||
return parent::filterServerRequest($searchRequest, false);
|
||||
}
|
||||
|
||||
public function collectionFromQuery(? string $entityClass = null) : EntityCollection
|
||||
{
|
||||
$this->eventRegister(new class implements CollectionFromQueryItemInterface {
|
||||
|
||||
public function execute(array &$data): array
|
||||
{
|
||||
# If user is member of only one group, it is received as a single string.
|
||||
$data['memberOf'] = isset($data['memberOf']) ? (array) $data['memberOf'] : null;
|
||||
|
||||
return $data;
|
||||
}
|
||||
});
|
||||
|
||||
return parent::collectionFromQuery($entityClass);
|
||||
}
|
||||
}
|
|
@ -135,4 +135,12 @@ trait ConditionTrait
|
|||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function dn(string $value) : Repository
|
||||
{
|
||||
$this->queryBuilder->dn($value);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
}
|
Loading…
Reference in New Issue