Skip to content

Commit f5d722d

Browse files
author
Jeff Bornemann
committed
Support for writing authorizable nodes (Users/Groups)
1 parent cdb47d9 commit f5d722d

25 files changed

+1767
-304
lines changed

docs/Running.adoc

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,3 +182,19 @@ Invalid:
182182
}
183183
```
184184

185+
=== Syncing Users and Groups
186+
187+
Grabbit has support for syncing users and groups. One *important note* about syncing users is that you must take care to avoid syncing the _admin user_.
188+
Jackrabbit will not allow modification of the admin user, so Grabbit will fail on a job that attempts to do so. You can find the path of your admin user
189+
on your data-warehouse instance or other source instance; and add it as an exclude path as so:
190+
191+
```
192+
pathConfigurations :
193+
-
194+
path : /home/groups
195+
-
196+
path : /home/users
197+
excludePaths:
198+
- k/ki9zhpzfe #Admin user
199+
```
200+

gradle.properties

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,13 +33,15 @@ jcr_version = 2.0
3333
jms_version = 3.1.1
3434
jsr305_version = 2.0.0
3535
logback_version = 1.0.4
36+
oak_version = 1.2.2
3637
objenesis_version = 2.1
3738
protobuf_gradle_plugin_version = 0.9.1
3839
protobuf_version = 2.6.1
3940
scr_annotations_version = 1.7.0
4041
servlet_api_version = 2.5
4142
slf4j_version = 1.7.6
4243
sling_api_version = 2.9.0
44+
sling_base_version = 2.2.2
4345
sling_commons_testing_version = 2.0.12
4446
sling_commons_version = 2.2.0
4547
sling_event_version = 3.1.4

gradle/dependencies.gradle

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ dependencies {
1919

2020
// Apache Sling libraries
2121
compile "org.apache.sling:org.apache.sling.api:${sling_api_version}"
22+
compile "org.apache.sling:org.apache.sling.jcr.base:${sling_base_version}"
2223
compile "org.apache.sling:org.apache.sling.jcr.resource:${sling_jcr_resource_version}"
2324

2425
// Apache Felix libraries
@@ -33,6 +34,8 @@ dependencies {
3334
// Working with the JCR
3435
compile "javax.jcr:jcr:${jcr_version}"
3536
compile "org.apache.jackrabbit:jackrabbit-jcr-commons:${jackrabbit_version}"
37+
compile "org.apache.jackrabbit:jackrabbit-api:${jackrabbit_version}"
38+
compile "org.apache.jackrabbit:oak-core:${oak_version}"
3639
compile "org.apache.sling:org.apache.sling.jcr.api:${sling_commons_version}"
3740

3841
// Logging

gradle/packageExclusions.gradle

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,11 +29,14 @@ configurations.cq_package {
2929
exclude group: 'org.apache.felix', module: 'org.osgi.compendium'
3030

3131
exclude group: 'org.apache.commons', module: 'commons-lang3'
32-
exclude group: 'org.apache.jackrabbit', module:'jackrabbit-jcr-commons'
32+
exclude group: 'org.apache.jackrabbit', module: 'jackrabbit-jcr-commons'
33+
exclude group: 'org.apache.jackrabbit', module: 'jackrabbit-api'
34+
exclude group: 'org.apache.jackrabbit', module: 'oak-core'
3335
exclude group: 'commons-io', module: 'commons-io'
3436

3537
//Exclude Apache Sling Libraries
3638
exclude group: 'org.apache.sling', module: 'org.apache.sling.api'
39+
exclude group: 'org.apache.sling', module: 'org.apache.sling.jcr.base'
3740
exclude group: 'org.apache.sling', module:'org.apache.sling.jcr.resource'
3841
exclude group: 'org.apache.sling', module: 'org.apache.sling.jcr.api'
3942

src/main/groovy/com/twcable/grabbit/client/batch/steps/jcrnodes/JcrNodesWriter.groovy

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
package com.twcable.grabbit.client.batch.steps.jcrnodes
1818

1919
import com.twcable.grabbit.client.batch.ClientBatchJobContext
20-
import com.twcable.grabbit.jcr.JcrNodeDecorator
20+
import com.twcable.grabbit.jcr.JCRNodeDecorator
2121
import com.twcable.grabbit.jcr.ProtoNodeDecorator
2222
import com.twcable.grabbit.proto.NodeProtos.Node as ProtoNode
2323
import groovy.transform.CompileStatic
@@ -88,14 +88,8 @@ class JcrNodesWriter implements ItemWriter<ProtoNode>, ItemWriteListener {
8888
}
8989

9090
private static void writeToJcr(ProtoNode nodeProto, Session session) {
91-
JcrNodeDecorator jcrNode = new ProtoNodeDecorator(nodeProto).writeToJcr(session)
91+
JCRNodeDecorator jcrNode = ProtoNodeDecorator.createFrom(nodeProto).writeToJcr(session)
9292
jcrNode.setLastModified()
93-
// This will processed all mandatory child nodes only
94-
if(nodeProto.mandatoryChildNodeList && nodeProto.mandatoryChildNodeList.size() > 0) {
95-
for(ProtoNode childNode: nodeProto.mandatoryChildNodeList) {
96-
writeToJcr(childNode, session)
97-
}
98-
}
9993
}
10094

10195
private Session theSession() {
Lines changed: 266 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,266 @@
1+
/*
2+
* Copyright 2015 Time Warner Cable, Inc.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.twcable.grabbit.jcr
18+
19+
import com.twcable.grabbit.proto.NodeProtos.Node as ProtoNode
20+
import com.twcable.grabbit.security.AuthorizablePrincipal
21+
import com.twcable.grabbit.security.InsufficientGrabbitPrivilegeException
22+
import com.twcable.grabbit.util.CryptoUtil
23+
import groovy.transform.CompileStatic
24+
import groovy.util.logging.Slf4j
25+
import java.lang.reflect.Field
26+
import java.lang.reflect.Method
27+
import java.lang.reflect.ReflectPermission
28+
import java.util.regex.Pattern
29+
import javax.annotation.Nonnull
30+
import javax.jcr.Session
31+
import org.apache.jackrabbit.api.security.user.Authorizable
32+
import org.apache.jackrabbit.api.security.user.Group
33+
import org.apache.jackrabbit.api.security.user.User
34+
import org.apache.jackrabbit.api.security.user.UserManager
35+
import org.apache.jackrabbit.value.StringValue
36+
import org.apache.sling.jcr.base.util.AccessControlUtil
37+
38+
39+
/**
40+
* This class wraps a serialized node that represents an Authorizable. Authorizables are special system protected nodes, that can only be written under certain
41+
* trees, and can not be written directly by a client.
42+
*/
43+
@CompileStatic
44+
@Slf4j
45+
class AuthorizableProtoNodeDecorator extends ProtoNodeDecorator {
46+
47+
48+
protected AuthorizableProtoNodeDecorator(@Nonnull ProtoNode node, @Nonnull Collection<ProtoPropertyDecorator> protoProperties) {
49+
this.innerProtoNode = node
50+
this.protoProperties = protoProperties
51+
}
52+
53+
54+
@Override
55+
JCRNodeDecorator writeToJcr(@Nonnull Session session) {
56+
if(!checkSecurityPermissions()) {
57+
throw new InsufficientGrabbitPrivilegeException("JVM Permissions needed by Grabbit to sync Users/Groups were not found. See log for specific permissions needed, and add these to your security manager; or do not sync users and groups." +
58+
"Unfortunately, the way Jackrabbit goes about certain things requires us to do a bit of hacking in order to sync Authorizables securely, and efficiently.")
59+
}
60+
Authorizable existingAuthorizable = findAuthorizable(session)
61+
Authorizable newAuthorizable = existingAuthorizable ? updateAuthorizable(existingAuthorizable, session) : createNewAuthorizable(session)
62+
writeAuthorizablePieces(newAuthorizable, session)
63+
return new JCRNodeDecorator(session.getNode(newAuthorizable.getPath()))
64+
}
65+
66+
67+
/**
68+
* @return a new authorizable from this serialized node
69+
*/
70+
private Authorizable createNewAuthorizable(final Session session) {
71+
final UserManager userManager = getUserManager(session)
72+
if(isUserType()) {
73+
//We set a temporary password for now, and then set the real password later in setPasswordForUser(). See the method for why.
74+
final newUser = userManager.createUser(authorizableID, Long.toString(CryptoUtil.generateNextId()), new AuthorizablePrincipal(authorizableID), getIntermediateAuthorizablePath())
75+
//This is a special protected property for disabling user access
76+
if(hasProperty('rep:disabled')) {
77+
newUser.disable(getStringValueFrom('rep:disabled'))
78+
}
79+
//AEM writes this property directly on the user node for some reason. One known use is for setting leads on MCM campaigns.
80+
final authorizableCategory = 'cq:authorizableCategory'
81+
if(hasProperty(authorizableCategory)) {
82+
newUser.setProperty(authorizableCategory, new StringValue(getStringValueFrom(authorizableCategory)))
83+
}
84+
session.save()
85+
//Special users may not have passwords, such as anonymous users
86+
if(hasProperty('rep:password')) {
87+
setPasswordForUser(newUser, session)
88+
}
89+
return newUser
90+
}
91+
final Group group = userManager.createGroup(authorizableID, new AuthorizablePrincipal(authorizableID), getIntermediateAuthorizablePath())
92+
session.save()
93+
return group
94+
}
95+
96+
97+
/**
98+
* From a client API perspective, there is really no way to truly update an existing authorizable node. All of the node properties are protected, and there is no
99+
* known way to update them. Here we remove the existing authorizable as denoted by the authorizableID, and recreate it.
100+
* @return new instance of updated authorizable
101+
*/
102+
private Authorizable updateAuthorizable(final Authorizable authorizable, final Session session) {
103+
authorizable.remove()
104+
session.save()
105+
createNewAuthorizable(session)
106+
}
107+
108+
109+
/**
110+
* Authorizable pieces (nodes that live under Authorizables - profile, preferences, etc) get sent with the authorizable node instead of streamed independently because we do not know the client's new
111+
* authorizable UUID node name at runtime. In other words, authorizables can live under different node names from server to server
112+
*/
113+
private void writeAuthorizablePieces(final Authorizable authorizable, final Session session) {
114+
innerProtoNode.mandatoryChildNodeList.each {
115+
//We replace the incoming server authorizable path, with the new authorizable path
116+
createFrom(it, it.name.replaceFirst(Pattern.quote(getName()), authorizable.getPath())).writeToJcr(session)
117+
}
118+
session.save()
119+
}
120+
121+
122+
private Authorizable findAuthorizable(final Session session) {
123+
final UserManager userManager = getUserManager(session)
124+
return userManager.getAuthorizable(getAuthorizableID())
125+
}
126+
127+
128+
private String getAuthorizableID() {
129+
return protoProperties.find { it.isAuthorizableIDType() }.stringValue
130+
}
131+
132+
133+
private String getIntermediateAuthorizablePath() {
134+
final pathTokens = getName().tokenize('/')
135+
//remove last index, as this is the Authorizable node name
136+
pathTokens.remove(pathTokens.size() - 1)
137+
return "/${pathTokens.join('/')}"
138+
}
139+
140+
141+
private boolean isUserType() {
142+
return protoProperties.any { it.userType }
143+
}
144+
145+
146+
/**
147+
* Some JVM's have a SecurityManager set, which based on configuration, can potentially inhibit our hack {@code setPasswordForUser(User, Session)} from working.
148+
* We need to check security permissions before proceeding
149+
* @return true if we can sync this Authorizable
150+
*/
151+
private boolean checkSecurityPermissions() {
152+
final SecurityManager securityManager = getSecurityManager()
153+
//If no security manager is present, then we are in the clear; otherwise, we need to check certain permissions
154+
if(!securityManager){
155+
log.debug "No SecurityManager found on this JVM. Sync of Users/Groups can continue"
156+
return true
157+
}
158+
final issues = []
159+
final badPermissions = false
160+
log.debug "SecurityManager found on this JVM. Checking permissions.."
161+
try {
162+
//Needed to reflect on members for which this class does not normally have access to
163+
securityManager.checkPermission(new ReflectPermission('suppressAccessChecks'))
164+
}
165+
catch(SecurityException ex) {
166+
issues << 'suppressAccessChecks'
167+
badPermissions = true
168+
}
169+
try {
170+
//Needed to access all declared members of a class, including protected or private
171+
securityManager.checkPermission(new RuntimePermission('accessDeclaredMembers'))
172+
}
173+
catch(SecurityException ex) {
174+
issues << 'accessDeclaredMembers'
175+
badPermissions = true
176+
}
177+
try {
178+
//Needed to access classes directly within a potentially system protected package
179+
securityManager.checkPermission(new RuntimePermission('accessClassInPackage.{org.apache.jackrabbit.oak.security.user}'))
180+
}
181+
catch(SecurityException ex) {
182+
issues << 'accessClassInPackage.{org.apache.jackrabbit.oak.security.user}'
183+
badPermissions = true
184+
}
185+
if(badPermissions) {
186+
log.warn "A SecurityManager is enabled for this JVM, and permissions are not sufficient for Grabbit to sync Authorizables (Users/Groups). You must enable ${issues.join(', ')} permissions in your SecurityManager to use this functionality" +
187+
"Check https://docs.oracle.com/javase/7/docs/api/java/lang/RuntimePermission.html and https://docs.oracle.com/javase/7/docs/api/java/lang/reflect/ReflectPermission.html to see what these permissions enable"
188+
return false
189+
}
190+
else {
191+
log.debug "Permissions check successful"
192+
return true
193+
}
194+
}
195+
196+
/**
197+
* Mostly for ease of mocking/testing
198+
* @return the system's security manager, or null if one is not present
199+
*/
200+
SecurityManager getSecurityManager() {
201+
return System.getSecurityManager()
202+
}
203+
204+
/**
205+
* Mostly for ease of mocking/testing
206+
*/
207+
UserManager getUserManager(final Session session) {
208+
return AccessControlUtil.getUserManager(session)
209+
}
210+
211+
212+
/**
213+
* Normally we would call org.apache.jackrabbit.oak.jcr.delegate.UserDelegator.changePassword(String password) to change a password (this is what is publicly available through the Jackrabbit API)
214+
* However, this method ALWAYS rehashes the password argument which is of no use to us, since we are trying to transfer an already hashed password.
215+
*
216+
* Internally, org.apache.jackrabbit.oak.jcr.delegate.UserDelegator calls it's delegate's org.apache.jackrabbit.oak.security.user.UserImpl.changePassword(String password)
217+
* which calls org.apache.jackrabbit.oak.security.user.UserManagerImpl.setPassword(Tree tree, String userId, String password, boolean forceHash) with forceHash always set to true
218+
* We really need forcehash set to false for our case, but this isn't publicly available. Here, we access internal objects to do this manipulation. org.apache.jackrabbit.oak.security.user.UserManagerImpl
219+
* simply ensures that forcehash is false, and that the password is not plain text, and it sets the password as-is.
220+
*
221+
* @throws IllegalStateException if security permissions required to run this are not there. @{code checkSecurityPermissions()} should be called before calling this method
222+
**/
223+
void setPasswordForUser(final User user, final Session session) {
224+
if(!checkSecurityPermissions()) throw new IllegalStateException("Security check failed for Grabbit. Can not set user passwords")
225+
//As a consumer we have access to org.apache.jackrabbit.oak.jcr.delegate.UserManagerDelegator below
226+
final userManager = getUserManager(session)
227+
Class userManagerDelegatorClass = userManager.getClass()
228+
//Reach into the class of this delegator, and grab the core Jackrabbit object we delegate to
229+
Field userManagerDelegateField = userManagerDelegatorClass.getDeclaredField('userManagerDelegate')
230+
//The delegate field is private, so we need to make it accessible. Security checks above are imperative for this to work
231+
userManagerDelegateField.setAccessible(true)
232+
//Here we have a handle to the internal class org.apache.jackrabbit.oak.security.user.UserManagerImpl
233+
final userManagerDelegate = userManagerDelegateField.get(userManager)
234+
final userManagerDelegateClass = userManagerDelegate.getClass()
235+
//We need to set the 'setPassword' method as accessible. Again, security checks above are imperative for this to work
236+
Method setPasswordMethod = userManagerDelegateClass.getDeclaredMethod('setPassword', Class.forName('org.apache.jackrabbit.oak.api.Tree', true, userManagerDelegateClass.getClassLoader()), String, String, boolean)
237+
setPasswordMethod.setAccessible(true)
238+
/**
239+
* Step two. We need access to the internal Authorizable object's tree in order to call the internal setPassword method
240+
* User is an instance of org.apache.jackrabbit.oak.jcr.delegate.UserDelegator. We need to get the delegate off of this class's super class org.apache.jackrabbit.oak.jcr.delegate.AuthorizableDelegator
241+
*/
242+
Class authorizableDelegateClass = user.getClass().getSuperclass()
243+
Field authorizableDelegateField = authorizableDelegateClass.getDeclaredField('delegate')
244+
authorizableDelegateField.setAccessible(true)
245+
final authorizable = authorizableDelegateField.get(user)
246+
//Internal org.apache.jackrabbit.oak.security.user.AuthorizableImpl object. We can access the protected tree here
247+
Method getTreeMethod = authorizable.getClass().getSuperclass().getDeclaredMethod('getTree')
248+
getTreeMethod.setAccessible(true)
249+
250+
/**
251+
* The last argument where we are passing in 'false' in the secret sauce we need. This parameter is forceHash. As long as forceHash is false, and the password is not
252+
* clear-text, which it isn't since we got it from another Jackrabbit instance, we can set the password as-is.
253+
*/
254+
setPasswordMethod.invoke(userManagerDelegate, getTreeMethod.invoke(authorizable), getAuthorizableID(), getStringValueFrom('rep:password'), false)
255+
session.save()
256+
}
257+
258+
259+
/**
260+
* An instance wrapper for ease of mocking
261+
* @see super.createFrom
262+
*/
263+
ProtoNodeDecorator createFrom(final ProtoNode protoNode, final String nameOverride) {
264+
super.createFrom(protoNode, nameOverride)
265+
}
266+
}

0 commit comments

Comments
 (0)