Skip to content

Commit 32d932d

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

23 files changed

Lines changed: 1458 additions & 262 deletions

gradle.properties

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ scr_annotations_version = 1.7.0
4040
servlet_api_version = 2.5
4141
slf4j_version = 1.7.6
4242
sling_api_version = 2.9.0
43+
sling_base_version = 2.2.2
4344
sling_commons_testing_version = 2.0.12
4445
sling_commons_version = 2.2.0
4546
sling_event_version = 3.1.4

gradle/dependencies.gradle

Lines changed: 2 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,7 @@ 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}"
3638
compile "org.apache.sling:org.apache.sling.jcr.api:${sling_commons_version}"
3739

3840
// Logging

gradle/packageExclusions.gradle

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,10 +30,12 @@ configurations.cq_package {
3030

3131
exclude group: 'org.apache.commons', module: 'commons-lang3'
3232
exclude group: 'org.apache.jackrabbit', module:'jackrabbit-jcr-commons'
33+
exclude group: 'org.apache.jackrabbit', module:'jackrabbit-api'
3334
exclude group: 'commons-io', module: 'commons-io'
3435

3536
//Exclude Apache Sling Libraries
3637
exclude group: 'org.apache.sling', module: 'org.apache.sling.api'
38+
exclude group: 'org.apache.sling', module: 'org.apache.sling.jcr.base'
3739
exclude group: 'org.apache.sling', module:'org.apache.sling.jcr.resource'
3840
exclude group: 'org.apache.sling', module: 'org.apache.sling.jcr.api'
3941

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

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

0 commit comments

Comments
 (0)