001/*
002 * (C) Copyright 2006-2016 Nuxeo SA (http://nuxeo.com/) and others.
003 *
004 * Licensed under the Apache License, Version 2.0 (the "License");
005 * you may not use this file except in compliance with the License.
006 * You may obtain a copy of the License at
007 *
008 *     http://www.apache.org/licenses/LICENSE-2.0
009 *
010 * Unless required by applicable law or agreed to in writing, software
011 * distributed under the License is distributed on an "AS IS" BASIS,
012 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
013 * See the License for the specific language governing permissions and
014 * limitations under the License.
015 *
016 * Contributors:
017 *     Florent Guillaume
018 */
019package org.nuxeo.ecm.core.storage.sql.coremodel;
020
021import java.io.Serializable;
022import java.text.DateFormat;
023import java.text.ParseException;
024import java.util.ArrayList;
025import java.util.Arrays;
026import java.util.Calendar;
027import java.util.Collections;
028import java.util.Comparator;
029import java.util.GregorianCalendar;
030import java.util.HashMap;
031import java.util.HashSet;
032import java.util.LinkedList;
033import java.util.List;
034import java.util.Map;
035import java.util.Map.Entry;
036import java.util.Set;
037import java.util.regex.Matcher;
038import java.util.regex.Pattern;
039
040import javax.resource.ResourceException;
041
042import org.apache.commons.logging.Log;
043import org.apache.commons.logging.LogFactory;
044import org.nuxeo.ecm.core.api.CoreSession;
045import org.nuxeo.ecm.core.api.DocumentNotFoundException;
046import org.nuxeo.ecm.core.api.IterableQueryResult;
047import org.nuxeo.ecm.core.api.PartialList;
048import org.nuxeo.ecm.core.api.ScrollResult;
049import org.nuxeo.ecm.core.api.VersionModel;
050import org.nuxeo.ecm.core.api.security.ACE;
051import org.nuxeo.ecm.core.api.security.ACL;
052import org.nuxeo.ecm.core.api.security.ACP;
053import org.nuxeo.ecm.core.api.security.Access;
054import org.nuxeo.ecm.core.api.security.SecurityConstants;
055import org.nuxeo.ecm.core.api.security.impl.ACLImpl;
056import org.nuxeo.ecm.core.api.security.impl.ACPImpl;
057import org.nuxeo.ecm.core.model.Document;
058import org.nuxeo.ecm.core.model.LockManager;
059import org.nuxeo.ecm.core.model.Repository;
060import org.nuxeo.ecm.core.model.Session;
061import org.nuxeo.ecm.core.query.QueryFilter;
062import org.nuxeo.ecm.core.query.sql.NXQL;
063import org.nuxeo.ecm.core.schema.DocumentType;
064import org.nuxeo.ecm.core.schema.SchemaManager;
065import org.nuxeo.ecm.core.storage.sql.ACLRow;
066import org.nuxeo.ecm.core.storage.sql.Model;
067import org.nuxeo.ecm.core.storage.sql.Node;
068import org.nuxeo.runtime.api.Framework;
069
070/**
071 * This class is the bridge between the Nuxeo SPI Session and the actual low-level implementation of the SQL storage
072 * session.
073 *
074 * @author Florent Guillaume
075 */
076public class SQLSession implements Session {
077
078    protected final Log log = LogFactory.getLog(SQLSession.class);
079
080    /**
081     * Framework property to control whether negative ACLs (deny) are allowed.
082     *
083     * @since 6.0
084     */
085    public static final String ALLOW_NEGATIVE_ACL_PROPERTY = "nuxeo.security.allowNegativeACL";
086
087    /**
088     * Framework property to disabled free-name collision detection for copy. This is useful when constraints have been
089     * added to the database to detect collisions at the database level and raise a ConcurrentUpdateException, thus
090     * letting the high-level application deal with collisions.
091     *
092     * @since 7.3
093     */
094    public static final String COPY_FINDFREENAME_DISABLED_PROP = "nuxeo.vcs.copy.findFreeName.disabled";
095
096    private final Repository repository;
097
098    private final org.nuxeo.ecm.core.storage.sql.Session session;
099
100    private Document root;
101
102    private final boolean negativeAclAllowed;
103
104    private final boolean copyFindFreeNameDisabled;
105
106    public SQLSession(org.nuxeo.ecm.core.storage.sql.Session session, Repository repository) {
107        this.session = session;
108        this.repository = repository;
109        Node rootNode = session.getRootNode();
110        root = newDocument(rootNode);
111        negativeAclAllowed = Framework.isBooleanPropertyTrue(ALLOW_NEGATIVE_ACL_PROPERTY);
112        copyFindFreeNameDisabled = Framework.isBooleanPropertyTrue(COPY_FINDFREENAME_DISABLED_PROP);
113    }
114
115    /*
116     * ----- org.nuxeo.ecm.core.model.Session -----
117     */
118
119    @Override
120    public Document getRootDocument() {
121        return root;
122    }
123
124    @Override
125    public Document getNullDocument() {
126        return new SQLDocumentLive(null, null, this, true);
127    }
128
129    @Override
130    public void close() {
131        root = null;
132        try {
133            session.close();
134        } catch (ResourceException e) {
135            throw new RuntimeException(e);
136        }
137    }
138
139    @Override
140    public void save() {
141        session.save();
142    }
143
144    @Override
145    public boolean isLive() {
146        // session can become non-live behind our back
147        // through ConnectionAwareXAResource that closes
148        // all handles (sessions) at tx end() time
149        return session != null && session.isLive();
150    }
151
152    @Override
153    public String getRepositoryName() {
154        return repository.getName();
155    }
156
157    protected String idToString(Serializable id) {
158        return session.getModel().idToString(id);
159    }
160
161    protected Serializable idFromString(String id) {
162        return session.getModel().idFromString(id);
163    }
164
165    @Override
166    public ScrollResult scroll(String query, int batchSize, int keepAliveSeconds) {
167        return session.scroll(query, batchSize, keepAliveSeconds);
168    }
169
170    @Override
171    public ScrollResult scroll(String scrollId) {
172        return session.scroll(scrollId);
173    }
174
175    @Override
176    public Document getDocumentByUUID(String uuid) throws DocumentNotFoundException {
177        /*
178         * Document ids coming from higher level have been turned into strings (by {@link SQLDocument#getUUID}) but the
179         * backend may actually expect them to be Longs (for database-generated integer ids).
180         */
181        Document doc = getDocumentById(idFromString(uuid));
182        if (doc == null) {
183            // required by callers such as AbstractSession.exists
184            throw new DocumentNotFoundException(uuid);
185        }
186        return doc;
187    }
188
189    @Override
190    public Document resolvePath(String path) throws DocumentNotFoundException {
191        if (path.endsWith("/") && path.length() > 1) {
192            path = path.substring(0, path.length() - 1);
193        }
194        Node node = session.getNodeByPath(path, session.getRootNode());
195        Document doc = newDocument(node);
196        if (doc == null) {
197            throw new DocumentNotFoundException(path);
198        }
199        return doc;
200    }
201
202    protected void orderBefore(Node node, Node src, Node dest) {
203        session.orderBefore(node, src, dest);
204    }
205
206    @Override
207    public Document move(Document source, Document parent, String name) {
208        assert source instanceof SQLDocument;
209        assert parent instanceof SQLDocument;
210        if (name == null) {
211            name = source.getName();
212        }
213        Node result = session.move(((SQLDocument) source).getNode(), ((SQLDocument) parent).getNode(), name);
214        return newDocument(result);
215    }
216
217    private static final Pattern dotDigitsPattern = Pattern.compile("(.*)\\.[0-9]+$");
218
219    protected String findFreeName(Node parentNode, String name) {
220        if (session.hasChildNode(parentNode, name, false)) {
221            Matcher m = dotDigitsPattern.matcher(name);
222            if (m.matches()) {
223                // remove trailing dot and digits
224                name = m.group(1);
225            }
226            // add dot + unique digits
227            name += "." + System.nanoTime();
228        }
229        return name;
230    }
231
232    @Override
233    public Document copy(Document source, Document parent, String name) {
234        assert source instanceof SQLDocument;
235        assert parent instanceof SQLDocument;
236        if (name == null) {
237            name = source.getName();
238        }
239        Node parentNode = ((SQLDocument) parent).getNode();
240        if (!copyFindFreeNameDisabled) {
241            name = findFreeName(parentNode, name);
242        }
243        Node copy = session.copy(((SQLDocument) source).getNode(), parentNode, name);
244        return newDocument(copy);
245    }
246
247    @Override
248    public Document getVersion(String versionableId, VersionModel versionModel) {
249        Serializable vid = idFromString(versionableId);
250        Node versionNode = session.getVersionByLabel(vid, versionModel.getLabel());
251        if (versionNode == null) {
252            return null;
253        }
254        versionModel.setDescription(versionNode.getSimpleProperty(Model.VERSION_DESCRIPTION_PROP).getString());
255        versionModel.setCreated((Calendar) versionNode.getSimpleProperty(Model.VERSION_CREATED_PROP).getValue());
256        return newDocument(versionNode);
257    }
258
259    @Override
260    public Document createProxy(Document doc, Document folder) {
261        Node folderNode = ((SQLDocument) folder).getNode();
262        Node targetNode = ((SQLDocument) doc).getNode();
263        Serializable targetId = targetNode.getId();
264        Serializable versionableId;
265        if (doc.isVersion()) {
266            versionableId = targetNode.getSimpleProperty(Model.VERSION_VERSIONABLE_PROP).getValue();
267        } else if (doc.isProxy()) {
268            // copy the proxy
269            targetId = targetNode.getSimpleProperty(Model.PROXY_TARGET_PROP).getValue();
270            versionableId = targetNode.getSimpleProperty(Model.PROXY_VERSIONABLE_PROP).getValue();
271        } else {
272            // working copy (live document)
273            versionableId = targetId;
274        }
275        String name = findFreeName(folderNode, doc.getName());
276        Node proxy = session.addProxy(targetId, versionableId, folderNode, name, null);
277        return newDocument(proxy);
278    }
279
280    @Override
281    public List<Document> getProxies(Document document, Document parent) {
282        List<Node> proxyNodes = session.getProxies(((SQLDocument) document).getNode(),
283                parent == null ? null : ((SQLDocument) parent).getNode());
284        List<Document> proxies = new ArrayList<>(proxyNodes.size());
285        for (Node proxyNode : proxyNodes) {
286            proxies.add(newDocument(proxyNode));
287        }
288        return proxies;
289    }
290
291    @Override
292    public void setProxyTarget(Document proxy, Document target) {
293        Node proxyNode = ((SQLDocument) proxy).getNode();
294        Serializable targetId = idFromString(target.getUUID());
295        session.setProxyTarget(proxyNode, targetId);
296    }
297
298    // returned document is r/w even if a version or a proxy, so that normal
299    // props can be set
300    @Override
301    public Document importDocument(String uuid, Document parent, String name, String typeName,
302            Map<String, Serializable> properties) {
303        boolean isProxy = typeName.equals(Model.PROXY_TYPE);
304        Map<String, Serializable> props = new HashMap<>();
305        Long pos = null; // TODO pos
306        if (!isProxy) {
307            // version & live document
308            props.put(Model.MISC_LIFECYCLE_POLICY_PROP, properties.get(CoreSession.IMPORT_LIFECYCLE_POLICY));
309            props.put(Model.MISC_LIFECYCLE_STATE_PROP, properties.get(CoreSession.IMPORT_LIFECYCLE_STATE));
310
311            Serializable importLockOwnerProp = properties.get(CoreSession.IMPORT_LOCK_OWNER);
312            if (importLockOwnerProp != null) {
313                props.put(Model.LOCK_OWNER_PROP, importLockOwnerProp);
314            }
315            Serializable importLockCreatedProp = properties.get(CoreSession.IMPORT_LOCK_CREATED);
316            if (importLockCreatedProp != null) {
317                props.put(Model.LOCK_CREATED_PROP, importLockCreatedProp);
318            }
319
320            props.put(Model.MAIN_MAJOR_VERSION_PROP, properties.get(CoreSession.IMPORT_VERSION_MAJOR));
321            props.put(Model.MAIN_MINOR_VERSION_PROP, properties.get(CoreSession.IMPORT_VERSION_MINOR));
322            props.put(Model.MAIN_IS_VERSION_PROP, properties.get(CoreSession.IMPORT_IS_VERSION));
323        }
324        Node parentNode;
325        if (parent == null) {
326            // version
327            parentNode = null;
328            props.put(Model.VERSION_VERSIONABLE_PROP,
329                    idFromString((String) properties.get(CoreSession.IMPORT_VERSION_VERSIONABLE_ID)));
330            props.put(Model.VERSION_CREATED_PROP, properties.get(CoreSession.IMPORT_VERSION_CREATED));
331            props.put(Model.VERSION_LABEL_PROP, properties.get(CoreSession.IMPORT_VERSION_LABEL));
332            props.put(Model.VERSION_DESCRIPTION_PROP, properties.get(CoreSession.IMPORT_VERSION_DESCRIPTION));
333            props.put(Model.VERSION_IS_LATEST_PROP, properties.get(CoreSession.IMPORT_VERSION_IS_LATEST));
334            props.put(Model.VERSION_IS_LATEST_MAJOR_PROP, properties.get(CoreSession.IMPORT_VERSION_IS_LATEST_MAJOR));
335        } else {
336            parentNode = ((SQLDocument) parent).getNode();
337            if (isProxy) {
338                // proxy
339                props.put(Model.PROXY_TARGET_PROP,
340                        idFromString((String) properties.get(CoreSession.IMPORT_PROXY_TARGET_ID)));
341                props.put(Model.PROXY_VERSIONABLE_PROP,
342                        idFromString((String) properties.get(CoreSession.IMPORT_PROXY_VERSIONABLE_ID)));
343            } else {
344                // live document
345                props.put(Model.MAIN_BASE_VERSION_PROP,
346                        idFromString((String) properties.get(CoreSession.IMPORT_BASE_VERSION_ID)));
347                props.put(Model.MAIN_CHECKED_IN_PROP, properties.get(CoreSession.IMPORT_CHECKED_IN));
348            }
349        }
350        return importChild(uuid, parentNode, name, pos, typeName, props);
351    }
352
353    protected static final Pattern ORDER_BY_PATH_ASC = Pattern.compile(
354            "(.*)\\s+ORDER\\s+BY\\s+" + NXQL.ECM_PATH + "\\s*$", Pattern.CASE_INSENSITIVE | Pattern.DOTALL);
355
356    protected static final Pattern ORDER_BY_PATH_DESC = Pattern.compile(
357            "(.*)\\s+ORDER\\s+BY\\s+" + NXQL.ECM_PATH + "\\s+DESC\\s*$", Pattern.CASE_INSENSITIVE | Pattern.DOTALL);
358
359    @Override
360    public PartialList<Document> query(String query, String queryType, QueryFilter queryFilter, long countUpTo) {
361        // do ORDER BY ecm:path by hand in SQLQueryResult as we can't
362        // do it in SQL (and has to do limit/offset as well)
363        Boolean orderByPath;
364        Matcher matcher = ORDER_BY_PATH_ASC.matcher(query);
365        if (matcher.matches()) {
366            orderByPath = Boolean.TRUE; // ASC
367        } else {
368            matcher = ORDER_BY_PATH_DESC.matcher(query);
369            if (matcher.matches()) {
370                orderByPath = Boolean.FALSE; // DESC
371            } else {
372                orderByPath = null;
373            }
374        }
375        long limit = 0;
376        long offset = 0;
377        if (orderByPath != null) {
378            query = matcher.group(1);
379            limit = queryFilter.getLimit();
380            offset = queryFilter.getOffset();
381            queryFilter = QueryFilter.withoutLimitOffset(queryFilter);
382        }
383        PartialList<Serializable> pl = session.query(query, queryType, queryFilter, countUpTo);
384
385        // get Documents in bulk, returns a newly-allocated ArrayList
386        List<Document> list = getDocumentsById(pl.list);
387
388        // order / limit
389        if (orderByPath != null) {
390            Collections.sort(list, new PathComparator(orderByPath.booleanValue()));
391        }
392        if (limit != 0) {
393            // do limit/offset by hand
394            int size = list.size();
395            list.subList(0, (int) (offset > size ? size : offset)).clear();
396            size = list.size();
397            if (limit < size) {
398                list.subList((int) limit, size).clear();
399            }
400        }
401        return new PartialList<>(list, pl.totalSize);
402    }
403
404    public static class PathComparator implements Comparator<Document> {
405
406        private final int sign;
407
408        public PathComparator(boolean asc) {
409            this.sign = asc ? 1 : -1;
410        }
411
412        @Override
413        public int compare(Document doc1, Document doc2) {
414            String p1 = doc1.getPath();
415            String p2 = doc2.getPath();
416            if (p1 == null && p2 == null) {
417                return sign * doc1.getUUID().compareTo(doc2.getUUID());
418            } else if (p1 == null) {
419                return sign;
420            } else if (p2 == null) {
421                return -1 * sign;
422            }
423            return sign * p1.compareTo(p2);
424        }
425    }
426
427    @Override
428    public IterableQueryResult queryAndFetch(String query, String queryType, QueryFilter queryFilter,
429            boolean distinctDocuments, Object[] params) {
430        return session.queryAndFetch(query, queryType, queryFilter, distinctDocuments, params);
431    }
432
433    /*
434     * ----- called by SQLDocument -----
435     */
436
437    private Document newDocument(Node node) {
438        return newDocument(node, true);
439    }
440
441    // "readonly" meaningful for proxies and versions, used for import
442    private Document newDocument(Node node, boolean readonly) {
443        if (node == null) {
444            // root's parent
445            return null;
446        }
447
448        Node targetNode = null;
449        String typeName = node.getPrimaryType();
450        if (node.isProxy()) {
451            Serializable targetId = node.getSimpleProperty(Model.PROXY_TARGET_PROP).getValue();
452            if (targetId == null) {
453                throw new DocumentNotFoundException("Proxy has null target");
454            }
455            targetNode = session.getNodeById(targetId);
456            typeName = targetNode.getPrimaryType();
457        }
458        SchemaManager schemaManager = Framework.getLocalService(SchemaManager.class);
459        DocumentType type = schemaManager.getDocumentType(typeName);
460        if (type == null) {
461            throw new DocumentNotFoundException("Unknown document type: " + typeName);
462        }
463
464        if (node.isProxy()) {
465            // proxy seen as a normal document
466            Document proxy = new SQLDocumentLive(node, type, this, false);
467            Document target = newDocument(targetNode, readonly);
468            return new SQLDocumentProxy(proxy, target);
469        } else if (node.isVersion()) {
470            return new SQLDocumentVersion(node, type, this, readonly);
471        } else {
472            return new SQLDocumentLive(node, type, this, false);
473        }
474    }
475
476    // called by SQLQueryResult iterator & others
477    protected Document getDocumentById(Serializable id) {
478        Node node = session.getNodeById(id);
479        return node == null ? null : newDocument(node);
480    }
481
482    // called by SQLQueryResult iterator
483    protected List<Document> getDocumentsById(List<Serializable> ids) {
484        List<Document> docs = new ArrayList<>(ids.size());
485        List<Node> nodes = session.getNodesByIds(ids);
486        for (int index = 0; index < ids.size(); ++index) {
487            Node eachNode = nodes.get(index);
488            if (eachNode == null) {
489                if (log.isTraceEnabled()) {
490                    Serializable id = ids.get(index);
491                    log.trace("Cannot fetch document with id: " + id, new Throwable("debug stack trace"));
492                }
493                continue;
494            }
495            Document doc;
496            try {
497                doc = newDocument(eachNode);
498            } catch (DocumentNotFoundException e) {
499                // unknown type in db, ignore
500                continue;
501            }
502            docs.add(doc);
503        }
504        return docs;
505    }
506
507    protected Document getParent(Node node) {
508        return newDocument(session.getParentNode(node));
509    }
510
511    protected String getPath(Node node) {
512        return session.getPath(node);
513    }
514
515    protected Document getChild(Node node, String name) throws DocumentNotFoundException {
516        Node childNode = session.getChildNode(node, name, false);
517        Document doc = newDocument(childNode);
518        if (doc == null) {
519            throw new DocumentNotFoundException(name);
520        }
521        return doc;
522    }
523
524    protected Node getChildProperty(Node node, String name, String typeName) {
525        // all complex property children have already been created by SessionImpl.addChildNode or
526        // SessionImpl.addMixinType
527        // if one is missing here, it means that it was concurrently deleted and we're only now finding out
528        // or that a schema change was done and we now expect a new child
529        // return null in that case
530        return session.getChildNode(node, name, true);
531    }
532
533    protected Node getChildPropertyForWrite(Node node, String name, String typeName) {
534        Node childNode = getChildProperty(node, name, typeName);
535        if (childNode == null) {
536            // create the needed complex property immediately
537            childNode = session.addChildNode(node, name, null, typeName, true);
538        }
539        return childNode;
540    }
541
542    protected List<Document> getChildren(Node node) {
543        List<Node> nodes = session.getChildren(node, null, false);
544        List<Document> children = new ArrayList<>(nodes.size());
545        for (Node n : nodes) {
546            try {
547                children.add(newDocument(n));
548            } catch (DocumentNotFoundException e) {
549                // ignore error retrieving one of the children
550                continue;
551            }
552        }
553        return children;
554    }
555
556    protected boolean hasChild(Node node, String name) {
557        return session.hasChildNode(node, name, false);
558    }
559
560    protected boolean hasChildren(Node node) {
561        return session.hasChildren(node, false);
562    }
563
564    protected Document addChild(Node parent, String name, Long pos, String typeName) {
565        return newDocument(session.addChildNode(parent, name, pos, typeName, false));
566    }
567
568    protected Node addChildProperty(Node parent, String name, Long pos, String typeName) {
569        return session.addChildNode(parent, name, pos, typeName, true);
570    }
571
572    protected Document importChild(String uuid, Node parent, String name, Long pos, String typeName,
573            Map<String, Serializable> props) {
574        Serializable id = idFromString(uuid);
575        Node node = session.addChildNode(id, parent, name, pos, typeName, false);
576        for (Entry<String, Serializable> entry : props.entrySet()) {
577            node.setSimpleProperty(entry.getKey(), entry.getValue());
578        }
579        return newDocument(node, false); // not readonly
580    }
581
582    protected boolean addMixinType(Node node, String mixin) {
583        return session.addMixinType(node, mixin);
584    }
585
586    protected boolean removeMixinType(Node node, String mixin) {
587        return session.removeMixinType(node, mixin);
588    }
589
590    protected List<Node> getComplexList(Node node, String name) {
591        List<Node> nodes = session.getChildren(node, name, true);
592        return nodes;
593    }
594
595    protected void remove(Node node) {
596        session.removeNode(node);
597    }
598
599    protected void removeProperty(Node node) {
600        session.removePropertyNode(node);
601    }
602
603    protected Document checkIn(Node node, String label, String checkinComment) {
604        Node versionNode = session.checkIn(node, label, checkinComment);
605        return versionNode == null ? null : newDocument(versionNode);
606    }
607
608    protected void checkOut(Node node) {
609        session.checkOut(node);
610    }
611
612    protected void restore(Node node, Node version) {
613        session.restore(node, version);
614    }
615
616    protected Document getVersionByLabel(String versionSeriesId, String label) {
617        Serializable vid = idFromString(versionSeriesId);
618        Node versionNode = session.getVersionByLabel(vid, label);
619        return versionNode == null ? null : newDocument(versionNode);
620    }
621
622    protected List<Document> getVersions(String versionSeriesId) {
623        Serializable vid = idFromString(versionSeriesId);
624        List<Node> versionNodes = session.getVersions(vid);
625        List<Document> versions = new ArrayList<>(versionNodes.size());
626        for (Node versionNode : versionNodes) {
627            versions.add(newDocument(versionNode));
628        }
629        return versions;
630    }
631
632    public Document getLastVersion(String versionSeriesId) {
633        Serializable vid = idFromString(versionSeriesId);
634        Node versionNode = session.getLastVersion(vid);
635        if (versionNode == null) {
636            return null;
637        }
638        return newDocument(versionNode);
639    }
640
641    protected Node getNodeById(Serializable id) {
642        return session.getNodeById(id);
643    }
644
645    @Override
646    public LockManager getLockManager() {
647        return session.getLockManager();
648    }
649
650    @Override
651    public boolean isNegativeAclAllowed() {
652        return negativeAclAllowed;
653    }
654
655    @Override
656    public void setACP(Document doc, ACP acp, boolean overwrite) {
657        if (!overwrite && acp == null) {
658            return;
659        }
660        checkNegativeAcl(acp);
661        Node node = ((SQLDocument) doc).getNode();
662        ACLRow[] aclrows;
663        if (overwrite) {
664            aclrows = acp == null ? null : acpToAclRows(acp);
665        } else {
666            aclrows = (ACLRow[]) node.getCollectionProperty(Model.ACL_PROP).getValue();
667            aclrows = updateAclRows(aclrows, acp);
668        }
669        node.getCollectionProperty(Model.ACL_PROP).setValue(aclrows);
670        session.requireReadAclsUpdate();
671    }
672
673    protected void checkNegativeAcl(ACP acp) {
674        if (negativeAclAllowed) {
675            return;
676        }
677        if (acp == null) {
678            return;
679        }
680        for (ACL acl : acp.getACLs()) {
681            if (acl.getName().equals(ACL.INHERITED_ACL)) {
682                continue;
683            }
684            for (ACE ace : acl.getACEs()) {
685                if (ace.isGranted()) {
686                    continue;
687                }
688                String permission = ace.getPermission();
689                if (permission.equals(SecurityConstants.EVERYTHING)
690                        && ace.getUsername().equals(SecurityConstants.EVERYONE)) {
691                    continue;
692                }
693                // allow Write, as we're sure it doesn't include Read/Browse
694                if (permission.equals(SecurityConstants.WRITE)) {
695                    continue;
696                }
697                throw new IllegalArgumentException("Negative ACL not allowed: " + ace);
698            }
699        }
700    }
701
702    @Override
703    public ACP getMergedACP(Document doc) {
704        Document base = doc.isVersion() ? doc.getSourceDocument() : doc;
705        if (base == null) {
706            return null;
707        }
708        ACP acp = getACP(base);
709        if (doc.getParent() == null) {
710            return acp;
711        }
712        // get inherited acls only if no blocking inheritance ACE exists in the top level acp.
713        ACL acl = null;
714        if (acp == null || acp.getAccess(SecurityConstants.EVERYONE, SecurityConstants.EVERYTHING) != Access.DENY) {
715            acl = getInheritedACLs(doc);
716        }
717        if (acp == null) {
718            if (acl == null) {
719                return null;
720            }
721            acp = new ACPImpl();
722        }
723        if (acl != null) {
724            acp.addACL(acl);
725        }
726        return acp;
727    }
728
729    /*
730     * ----- internal methods -----
731     */
732
733    protected ACP getACP(Document doc) {
734        Node node = ((SQLDocument) doc).getNode();
735        ACLRow[] aclrows = (ACLRow[]) node.getCollectionProperty(Model.ACL_PROP).getValue();
736        return aclRowsToACP(aclrows);
737    }
738
739    // unit tested
740    protected static ACP aclRowsToACP(ACLRow[] acls) {
741        ACP acp = new ACPImpl();
742        ACL acl = null;
743        String name = null;
744        for (ACLRow aclrow : acls) {
745            if (!aclrow.name.equals(name)) {
746                if (acl != null) {
747                    acp.addACL(acl);
748                }
749                name = aclrow.name;
750                acl = new ACLImpl(name);
751            }
752            // XXX should prefix user/group
753            String user = aclrow.user;
754            if (user == null) {
755                user = aclrow.group;
756            }
757            acl.add(ACE.builder(user, aclrow.permission)
758                       .isGranted(aclrow.grant)
759                       .creator(aclrow.creator)
760                       .begin(aclrow.begin)
761                       .end(aclrow.end)
762                       .build());
763        }
764        if (acl != null) {
765            acp.addACL(acl);
766        }
767        return acp;
768    }
769
770    // unit tested
771    protected static ACLRow[] acpToAclRows(ACP acp) {
772        List<ACLRow> aclrows = new LinkedList<>();
773        for (ACL acl : acp.getACLs()) {
774            String name = acl.getName();
775            if (name.equals(ACL.INHERITED_ACL)) {
776                continue;
777            }
778            for (ACE ace : acl.getACEs()) {
779                addACLRow(aclrows, name, ace);
780            }
781        }
782        ACLRow[] array = new ACLRow[aclrows.size()];
783        return aclrows.toArray(array);
784    }
785
786    // unit tested
787    protected static ACLRow[] updateAclRows(ACLRow[] aclrows, ACP acp) {
788        List<ACLRow> newaclrows = new LinkedList<>();
789        Map<String, ACL> aclmap = new HashMap<>();
790        for (ACL acl : acp.getACLs()) {
791            String name = acl.getName();
792            if (ACL.INHERITED_ACL.equals(name)) {
793                continue;
794            }
795            aclmap.put(name, acl);
796        }
797        List<ACE> aces = Collections.emptyList();
798        Set<String> aceKeys = null;
799        String name = null;
800        for (ACLRow aclrow : aclrows) {
801            // new acl?
802            if (!aclrow.name.equals(name)) {
803                // finish remaining aces
804                for (ACE ace : aces) {
805                    addACLRow(newaclrows, name, ace);
806                }
807                // start next round
808                name = aclrow.name;
809                ACL acl = aclmap.remove(name);
810                aces = acl == null ? Collections.<ACE> emptyList() : new LinkedList<>(Arrays.asList(acl.getACEs()));
811                aceKeys = new HashSet<>();
812                for (ACE ace : aces) {
813                    aceKeys.add(getACEkey(ace));
814                }
815            }
816            if (!aceKeys.contains(getACLrowKey(aclrow))) {
817                // no match, keep the aclrow info instead of the ace
818                newaclrows.add(new ACLRow(newaclrows.size(), name, aclrow.grant, aclrow.permission, aclrow.user,
819                        aclrow.group, aclrow.creator, aclrow.begin, aclrow.end, aclrow.status));
820            }
821        }
822        // finish remaining aces for last acl done
823        for (ACE ace : aces) {
824            addACLRow(newaclrows, name, ace);
825        }
826        // do non-done acls
827        for (ACL acl : aclmap.values()) {
828            name = acl.getName();
829            for (ACE ace : acl.getACEs()) {
830                addACLRow(newaclrows, name, ace);
831            }
832        }
833        ACLRow[] array = new ACLRow[newaclrows.size()];
834        return newaclrows.toArray(array);
835    }
836
837    /** Key to distinguish ACEs */
838    protected static String getACEkey(ACE ace) {
839        // TODO separate user/group
840        return ace.getUsername() + '|' + ace.getPermission();
841    }
842
843    /** Key to distinguish ACLRows */
844    protected static String getACLrowKey(ACLRow aclrow) {
845        // TODO separate user/group
846        String user = aclrow.user;
847        if (user == null) {
848            user = aclrow.group;
849        }
850        return user + '|' + aclrow.permission;
851    }
852
853    protected static void addACLRow(List<ACLRow> aclrows, String name, ACE ace) {
854        // XXX should prefix user/group
855        String user = ace.getUsername();
856        if (user == null) {
857            // JCR implementation logs null and skips it
858            return;
859        }
860        String group = null; // XXX all in user for now
861        aclrows.add(new ACLRow(aclrows.size(), name, ace.isGranted(), ace.getPermission(), user, group,
862                ace.getCreator(), ace.getBegin(), ace.getEnd(), ace.getLongStatus()));
863    }
864
865    protected ACL getInheritedACLs(Document doc) {
866        doc = doc.getParent();
867        ACL merged = null;
868        while (doc != null) {
869            ACP acp = getACP(doc);
870            if (acp != null) {
871                ACL acl = acp.getMergedACLs(ACL.INHERITED_ACL);
872                if (merged == null) {
873                    merged = acl;
874                } else {
875                    merged.addAll(acl);
876                }
877                if (acp.getAccess(SecurityConstants.EVERYONE, SecurityConstants.EVERYTHING) == Access.DENY) {
878                    break;
879                }
880            }
881            doc = doc.getParent();
882        }
883        return merged;
884    }
885
886    @Override
887    public Map<String, String> getBinaryFulltext(String id) {
888        return session.getBinaryFulltext(idFromString(id));
889    }
890
891    public boolean isChangeTokenEnabled() {
892        return session.isChangeTokenEnabled();
893    }
894
895}